@logictan/dsh-config-manager 0.1.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/cordis.patch.yml +21 -0
- package/lib/adapters/agent-instructions.d.ts +13 -0
- package/lib/adapters/agent-instructions.js +46 -0
- package/lib/adapters/agent-presets.d.ts +13 -0
- package/lib/adapters/agent-presets.js +14 -0
- package/lib/adapters/credentials.d.ts +37 -0
- package/lib/adapters/credentials.js +122 -0
- package/lib/adapters/file-collection.d.ts +15 -0
- package/lib/adapters/file-collection.js +127 -0
- package/lib/adapters/index.d.ts +57 -0
- package/lib/adapters/index.js +55 -0
- package/lib/adapters/link-report.d.ts +16 -0
- package/lib/adapters/link-report.js +47 -0
- package/lib/adapters/mcp.d.ts +28 -0
- package/lib/adapters/mcp.js +178 -0
- package/lib/adapters/plugin-files.d.ts +18 -0
- package/lib/adapters/plugin-files.js +142 -0
- package/lib/adapters/plugins.d.ts +41 -0
- package/lib/adapters/plugins.js +603 -0
- package/lib/adapters/pnpm-workspace.d.ts +26 -0
- package/lib/adapters/pnpm-workspace.js +147 -0
- package/lib/adapters/prompts.d.ts +30 -0
- package/lib/adapters/prompts.js +231 -0
- package/lib/adapters/providers.d.ts +34 -0
- package/lib/adapters/providers.js +155 -0
- package/lib/adapters/self.d.ts +17 -0
- package/lib/adapters/self.js +71 -0
- package/lib/adapters/sessions.d.ts +14 -0
- package/lib/adapters/sessions.js +15 -0
- package/lib/adapters/settings.d.ts +28 -0
- package/lib/adapters/settings.js +165 -0
- package/lib/adapters/skills.d.ts +13 -0
- package/lib/adapters/skills.js +14 -0
- package/lib/adapters/test-helpers.d.ts +125 -0
- package/lib/adapters/test-helpers.js +236 -0
- package/lib/adapters/ui.d.ts +27 -0
- package/lib/adapters/ui.js +89 -0
- package/lib/adapters/workspaces.d.ts +13 -0
- package/lib/adapters/workspaces.js +106 -0
- package/lib/client.d.ts +1026 -0
- package/lib/client.js +10047 -0
- package/lib/core/analyzer.d.ts +99 -0
- package/lib/core/analyzer.js +934 -0
- package/lib/core/backup.d.ts +138 -0
- package/lib/core/backup.js +513 -0
- package/lib/core/boot-rescue.d.ts +146 -0
- package/lib/core/boot-rescue.js +618 -0
- package/lib/core/cache-cleaner.d.ts +31 -0
- package/lib/core/cache-cleaner.js +95 -0
- package/lib/core/config-lifecycle.d.ts +180 -0
- package/lib/core/config-lifecycle.js +481 -0
- package/lib/core/config-snapshot.d.ts +135 -0
- package/lib/core/config-snapshot.js +424 -0
- package/lib/core/config-state.d.ts +70 -0
- package/lib/core/config-state.js +177 -0
- package/lib/core/consult-source.d.ts +58 -0
- package/lib/core/consult-source.js +260 -0
- package/lib/core/crash-report.d.ts +84 -0
- package/lib/core/crash-report.js +317 -0
- package/lib/core/exporter.d.ts +68 -0
- package/lib/core/exporter.js +375 -0
- package/lib/core/ghost-sweep.d.ts +19 -0
- package/lib/core/ghost-sweep.js +52 -0
- package/lib/core/importer.d.ts +60 -0
- package/lib/core/importer.js +29 -0
- package/lib/core/index.d.ts +14 -0
- package/lib/core/index.js +14 -0
- package/lib/core/journal.d.ts +197 -0
- package/lib/core/journal.js +418 -0
- package/lib/core/local-plugin-host.d.ts +35 -0
- package/lib/core/local-plugin-host.js +120 -0
- package/lib/core/local-plugin-pack.d.ts +135 -0
- package/lib/core/local-plugin-pack.js +278 -0
- package/lib/core/messages.d.ts +310 -0
- package/lib/core/messages.js +621 -0
- package/lib/core/migration-consult.d.ts +117 -0
- package/lib/core/migration-consult.js +363 -0
- package/lib/core/migration-history.d.ts +153 -0
- package/lib/core/migration-history.js +411 -0
- package/lib/core/model-tools.d.ts +50 -0
- package/lib/core/model-tools.js +148 -0
- package/lib/core/msg-types.d.ts +8 -0
- package/lib/core/msg-types.js +6 -0
- package/lib/core/phase3-child-crash.d.ts +1 -0
- package/lib/core/phase3-child-crash.js +87 -0
- package/lib/core/phase3-host.d.ts +138 -0
- package/lib/core/phase3-host.js +359 -0
- package/lib/core/phase3-prod-child.d.ts +1 -0
- package/lib/core/phase3-prod-child.js +35 -0
- package/lib/core/phase4-crash-child.d.ts +1 -0
- package/lib/core/phase4-crash-child.js +103 -0
- package/lib/core/plugin-cli.d.ts +150 -0
- package/lib/core/plugin-cli.js +497 -0
- package/lib/core/reconcile.d.ts +120 -0
- package/lib/core/reconcile.js +389 -0
- package/lib/core/recovery-orchestrator.d.ts +94 -0
- package/lib/core/recovery-orchestrator.js +327 -0
- package/lib/core/restore.d.ts +162 -0
- package/lib/core/restore.js +703 -0
- package/lib/core/rollback.d.ts +12 -0
- package/lib/core/rollback.js +145 -0
- package/lib/core/run-registry.d.ts +85 -0
- package/lib/core/run-registry.js +157 -0
- package/lib/core/startup-barrier.d.ts +53 -0
- package/lib/core/startup-barrier.js +77 -0
- package/lib/core/types.d.ts +515 -0
- package/lib/core/types.js +27 -0
- package/lib/core/undo.d.ts +73 -0
- package/lib/core/undo.js +87 -0
- package/lib/core/validator.d.ts +18 -0
- package/lib/core/validator.js +77 -0
- package/lib/core/verify-recovery.d.ts +21 -0
- package/lib/core/verify-recovery.js +256 -0
- package/lib/core/watcher.d.ts +101 -0
- package/lib/core/watcher.js +219 -0
- package/lib/index.d.ts +238 -0
- package/lib/index.js +2764 -0
- package/lib/migrations/index.d.ts +29 -0
- package/lib/migrations/index.js +56 -0
- package/lib/migrations/v1-to-v2.d.ts +9 -0
- package/lib/migrations/v1-to-v2.js +10 -0
- package/lib/profiles/index.d.ts +4 -0
- package/lib/profiles/index.js +5 -0
- package/lib/profiles/profile-manager.d.ts +106 -0
- package/lib/profiles/profile-manager.js +543 -0
- package/lib/schema/config.d.ts +28 -0
- package/lib/schema/config.js +165 -0
- package/lib/schema/index.d.ts +24 -0
- package/lib/schema/index.js +26 -0
- package/lib/schema/manifest.d.ts +28 -0
- package/lib/schema/manifest.js +109 -0
- package/lib/schema/tombstones.d.ts +46 -0
- package/lib/schema/tombstones.js +64 -0
- package/lib/schema/types.d.ts +232 -0
- package/lib/schema/types.js +8 -0
- package/lib/schema/versions.d.ts +27 -0
- package/lib/schema/versions.js +50 -0
- package/lib/security/encryption.d.ts +85 -0
- package/lib/security/encryption.js +279 -0
- package/lib/security/index.d.ts +16 -0
- package/lib/security/index.js +17 -0
- package/lib/security/integrity.d.ts +30 -0
- package/lib/security/integrity.js +78 -0
- package/lib/security/redaction.d.ts +26 -0
- package/lib/security/redaction.js +95 -0
- package/lib/security/secret-scanner.d.ts +86 -0
- package/lib/security/secret-scanner.js +404 -0
- package/lib/security/vault.d.ts +59 -0
- package/lib/security/vault.js +151 -0
- package/lib/security/zip-security.d.ts +27 -0
- package/lib/security/zip-security.js +154 -0
- package/lib/sync/autosync-config.d.ts +39 -0
- package/lib/sync/autosync-config.js +151 -0
- package/lib/sync/autosync-scheduler.d.ts +139 -0
- package/lib/sync/autosync-scheduler.js +564 -0
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +49 -0
- package/lib/sync/git/git-transport.d.ts +100 -0
- package/lib/sync/git/git-transport.js +437 -0
- package/lib/sync/github-auth.d.ts +131 -0
- package/lib/sync/github-auth.js +264 -0
- package/lib/sync/layout.d.ts +43 -0
- package/lib/sync/layout.js +203 -0
- package/lib/sync/snapshot-json.d.ts +9 -0
- package/lib/sync/snapshot-json.js +107 -0
- package/lib/sync/sync-config.d.ts +81 -0
- package/lib/sync/sync-config.js +281 -0
- package/lib/sync/sync-engine.d.ts +269 -0
- package/lib/sync/sync-engine.js +591 -0
- package/lib/sync/sync-history.d.ts +42 -0
- package/lib/sync/sync-history.js +78 -0
- package/lib/sync/sync-selection.d.ts +29 -0
- package/lib/sync/sync-selection.js +127 -0
- package/lib/sync/sync-session.d.ts +40 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-state.d.ts +37 -0
- package/lib/sync/sync-state.js +108 -0
- package/lib/sync/transport.d.ts +75 -0
- package/lib/sync/transport.js +55 -0
- package/lib/sync/ui-prefs.d.ts +34 -0
- package/lib/sync/ui-prefs.js +109 -0
- package/lib/sync/webdav/webdav-transport.d.ts +77 -0
- package/lib/sync/webdav/webdav-transport.js +451 -0
- package/lib/ui/errors.d.ts +19 -0
- package/lib/ui/errors.js +81 -0
- package/lib/ui/export-flow.d.ts +82 -0
- package/lib/ui/export-flow.js +98 -0
- package/lib/ui/i18n.d.ts +289 -0
- package/lib/ui/i18n.js +581 -0
- package/lib/ui/migration-consult-view.d.ts +91 -0
- package/lib/ui/migration-consult-view.js +115 -0
- package/lib/ui/progress.d.ts +38 -0
- package/lib/ui/progress.js +87 -0
- package/lib/ui/report.d.ts +23 -0
- package/lib/ui/report.js +153 -0
- package/lib/ui/test-helpers.d.ts +69 -0
- package/lib/ui/test-helpers.js +154 -0
- package/lib/ui/types.d.ts +257 -0
- package/lib/ui/types.js +21 -0
- package/lib/utils/atomic-write.d.ts +88 -0
- package/lib/utils/atomic-write.js +399 -0
- package/lib/utils/env-lock.d.ts +393 -0
- package/lib/utils/env-lock.js +930 -0
- package/lib/utils/hashing.d.ts +17 -0
- package/lib/utils/hashing.js +41 -0
- package/lib/utils/json.d.ts +24 -0
- package/lib/utils/json.js +76 -0
- package/lib/utils/logger.d.ts +39 -0
- package/lib/utils/logger.js +82 -0
- package/lib/utils/paths.d.ts +42 -0
- package/lib/utils/paths.js +189 -0
- package/lib/utils/proxy.d.ts +69 -0
- package/lib/utils/proxy.js +455 -0
- package/lib/utils/recursive-walk.d.ts +24 -0
- package/lib/utils/recursive-walk.js +134 -0
- package/lib/utils/zip.d.ts +54 -0
- package/lib/utils/zip.js +285 -0
- package/package.json +188 -0
- package/src/adapters/agent-instructions.ts +48 -0
- package/src/adapters/agent-presets.ts +14 -0
- package/src/adapters/credentials.test.ts +90 -0
- package/src/adapters/credentials.ts +143 -0
- package/src/adapters/file-collection.ts +137 -0
- package/src/adapters/files.test.ts +353 -0
- package/src/adapters/index.ts +94 -0
- package/src/adapters/link-report.test.ts +65 -0
- package/src/adapters/link-report.ts +61 -0
- package/src/adapters/mcp.test.ts +96 -0
- package/src/adapters/mcp.ts +185 -0
- package/src/adapters/plugin-files.ts +146 -0
- package/src/adapters/plugins.test.ts +326 -0
- package/src/adapters/plugins.ts +615 -0
- package/src/adapters/pnpm-workspace.test.ts +87 -0
- package/src/adapters/pnpm-workspace.ts +150 -0
- package/src/adapters/prompts.test.ts +110 -0
- package/src/adapters/prompts.ts +230 -0
- package/src/adapters/providers.test.ts +126 -0
- package/src/adapters/providers.ts +173 -0
- package/src/adapters/roundtrip.test.ts +190 -0
- package/src/adapters/self.test.ts +92 -0
- package/src/adapters/self.ts +75 -0
- package/src/adapters/sessions.ts +15 -0
- package/src/adapters/settings.test.ts +100 -0
- package/src/adapters/settings.ts +187 -0
- package/src/adapters/skills.ts +14 -0
- package/src/adapters/test-helpers.ts +248 -0
- package/src/adapters/ui.test.ts +57 -0
- package/src/adapters/ui.ts +108 -0
- package/src/adapters/workspaces.test.ts +106 -0
- package/src/adapters/workspaces.ts +109 -0
- package/src/client/ConfigManagerSection.tsx +90 -0
- package/src/client/api.ts +66 -0
- package/src/client/client-types.ts +18 -0
- package/src/client/common/ErrorBanner.tsx +73 -0
- package/src/client/common/Icon.tsx +129 -0
- package/src/client/common/Modal.tsx +173 -0
- package/src/client/common/ToastViewport.tsx +73 -0
- package/src/client/common/toast-store.test.ts +192 -0
- package/src/client/common/toast-store.ts +181 -0
- package/src/client/common/toast-types.ts +7 -0
- package/src/client/common/ui.tsx +342 -0
- package/src/client/config-manager.module.css +2971 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +56 -0
- package/src/client/lucide-icons.d.ts +38 -0
- package/src/client/run-store.test.ts +223 -0
- package/src/client/run-store.ts +372 -0
- package/src/client/sync/SyncConfirmView.tsx +379 -0
- package/src/client/sync/SyncHistoryView.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +220 -0
- package/src/client/sync/SyncSettingsView.tsx +1535 -0
- package/src/client/sync/history-model.test.ts +194 -0
- package/src/client/sync/history-model.ts +195 -0
- package/src/client/sync/sync-api.test.ts +585 -0
- package/src/client/sync/sync-api.ts +543 -0
- package/src/client/sync/sync-locales.ts +416 -0
- package/src/client/sync/sync-push-preview.test.ts +52 -0
- package/src/client/sync/sync-view-v2.test.ts +213 -0
- package/src/client/sync/sync-view.test.ts +487 -0
- package/src/client/sync/sync-view.ts +759 -0
- package/src/core/analyzer.ts +1068 -0
- package/src/core/backup.ts +605 -0
- package/src/core/boot-rescue.test.ts +847 -0
- package/src/core/boot-rescue.ts +737 -0
- package/src/core/cache-cleaner.test.ts +169 -0
- package/src/core/cache-cleaner.ts +121 -0
- package/src/core/config-lifecycle.test.ts +727 -0
- package/src/core/config-lifecycle.ts +573 -0
- package/src/core/config-snapshot.test.ts +369 -0
- package/src/core/config-snapshot.ts +522 -0
- package/src/core/config-state.test.ts +381 -0
- package/src/core/config-state.ts +207 -0
- package/src/core/consult-source.test.ts +199 -0
- package/src/core/consult-source.ts +293 -0
- package/src/core/crash-report.test.ts +380 -0
- package/src/core/crash-report.ts +369 -0
- package/src/core/exporter.ts +417 -0
- package/src/core/ghost-sweep.test.ts +94 -0
- package/src/core/ghost-sweep.ts +67 -0
- package/src/core/importer.ts +83 -0
- package/src/core/index.ts +56 -0
- package/src/core/journal.test.ts +257 -0
- package/src/core/journal.ts +518 -0
- package/src/core/local-plugin-host.ts +152 -0
- package/src/core/local-plugin-pack.test.ts +359 -0
- package/src/core/local-plugin-pack.ts +363 -0
- package/src/core/messages.test.ts +54 -0
- package/src/core/messages.ts +664 -0
- package/src/core/migration-consult.test.ts +315 -0
- package/src/core/migration-consult.ts +494 -0
- package/src/core/migration-history.test.ts +319 -0
- package/src/core/migration-history.ts +484 -0
- package/src/core/model-tools.test.ts +190 -0
- package/src/core/model-tools.ts +208 -0
- package/src/core/msg-types.ts +10 -0
- package/src/core/phase3-child-crash.ts +95 -0
- package/src/core/phase3-host.ts +411 -0
- package/src/core/phase3-p1.test.ts +212 -0
- package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
- package/src/core/phase3-prod-child.ts +36 -0
- package/src/core/phase3-production-integration.test.ts +286 -0
- package/src/core/phase4-crash-child.ts +101 -0
- package/src/core/phase4-crash-injection.test.ts +166 -0
- package/src/core/plugin-cli.fs.test.ts +184 -0
- package/src/core/plugin-cli.test.ts +137 -0
- package/src/core/plugin-cli.ts +563 -0
- package/src/core/reconcile.test.ts +392 -0
- package/src/core/reconcile.ts +498 -0
- package/src/core/recovery-orchestrator.test.ts +145 -0
- package/src/core/recovery-orchestrator.ts +366 -0
- package/src/core/restore-manage.test.ts +94 -0
- package/src/core/restore.ts +849 -0
- package/src/core/rollback.ts +151 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +232 -0
- package/src/core/run-registry.ts +209 -0
- package/src/core/smoke.test.ts +842 -0
- package/src/core/startup-barrier.ts +99 -0
- package/src/core/types.ts +542 -0
- package/src/core/undo.ts +116 -0
- package/src/core/validator.ts +84 -0
- package/src/core/verify-recovery.ts +265 -0
- package/src/core/watcher.test.ts +326 -0
- package/src/core/watcher.ts +263 -0
- package/src/index.facade.test.ts +265 -0
- package/src/index.sync.test.ts +217 -0
- package/src/index.ts +2975 -0
- package/src/migrations/index.ts +71 -0
- package/src/migrations/v1-to-v2.ts +17 -0
- package/src/profiles/index.ts +8 -0
- package/src/profiles/profile-manager.test.ts +282 -0
- package/src/profiles/profile-manager.ts +630 -0
- package/src/schema/config.ts +165 -0
- package/src/schema/index.ts +71 -0
- package/src/schema/manifest-schema.test.ts +519 -0
- package/src/schema/manifest.ts +124 -0
- package/src/schema/tombstones.test.ts +131 -0
- package/src/schema/tombstones.ts +89 -0
- package/src/schema/types.ts +245 -0
- package/src/schema/versions.ts +55 -0
- package/src/security/encryption.ts +335 -0
- package/src/security/index.ts +16 -0
- package/src/security/integrity.ts +102 -0
- package/src/security/redaction.ts +101 -0
- package/src/security/secret-scanner.ts +445 -0
- package/src/security/security.test.ts +1215 -0
- package/src/security/vault.test.ts +164 -0
- package/src/security/vault.ts +198 -0
- package/src/security/zip-security.ts +180 -0
- package/src/sync/autosync-config.test.ts +147 -0
- package/src/sync/autosync-config.ts +179 -0
- package/src/sync/autosync-scheduler.test.ts +490 -0
- package/src/sync/autosync-scheduler.ts +646 -0
- package/src/sync/fs.ts +48 -0
- package/src/sync/git/git-transport.test.ts +673 -0
- package/src/sync/git/git-transport.ts +491 -0
- package/src/sync/github-auth.test.ts +280 -0
- package/src/sync/github-auth.ts +356 -0
- package/src/sync/layout.test.ts +270 -0
- package/src/sync/layout.ts +222 -0
- package/src/sync/snapshot-json.test.ts +119 -0
- package/src/sync/snapshot-json.ts +128 -0
- package/src/sync/sync-config.test.ts +310 -0
- package/src/sync/sync-config.ts +299 -0
- package/src/sync/sync-engine.test.ts +1074 -0
- package/src/sync/sync-engine.ts +754 -0
- package/src/sync/sync-history.test.ts +116 -0
- package/src/sync/sync-history.ts +120 -0
- package/src/sync/sync-selection.test.ts +186 -0
- package/src/sync/sync-selection.ts +143 -0
- package/src/sync/sync-session.test.ts +143 -0
- package/src/sync/sync-session.ts +77 -0
- package/src/sync/sync-state.test.ts +164 -0
- package/src/sync/sync-state.ts +131 -0
- package/src/sync/transport.test.ts +111 -0
- package/src/sync/transport.ts +118 -0
- package/src/sync/ui-prefs.test.ts +152 -0
- package/src/sync/ui-prefs.ts +135 -0
- package/src/sync/webdav/webdav-transport.test.ts +871 -0
- package/src/sync/webdav/webdav-transport.ts +535 -0
- package/src/ui/errors.test.ts +47 -0
- package/src/ui/errors.ts +100 -0
- package/src/ui/export-flow.test.ts +87 -0
- package/src/ui/export-flow.ts +156 -0
- package/src/ui/i18n.ts +590 -0
- package/src/ui/migration-consult-view.test.ts +151 -0
- package/src/ui/migration-consult-view.ts +178 -0
- package/src/ui/progress.test.ts +47 -0
- package/src/ui/progress.ts +102 -0
- package/src/ui/report.test.ts +101 -0
- package/src/ui/report.ts +146 -0
- package/src/ui/test-helpers.ts +184 -0
- package/src/ui/types.ts +325 -0
- package/src/utils/atomic-write.test.ts +312 -0
- package/src/utils/atomic-write.ts +422 -0
- package/src/utils/env-lock.test.ts +999 -0
- package/src/utils/env-lock.ts +1086 -0
- package/src/utils/hashing.ts +53 -0
- package/src/utils/json.ts +77 -0
- package/src/utils/logger.ts +114 -0
- package/src/utils/paths.ts +176 -0
- package/src/utils/proxy.test.ts +465 -0
- package/src/utils/proxy.ts +493 -0
- package/src/utils/recursive-walk.test.ts +148 -0
- package/src/utils/recursive-walk.ts +145 -0
- package/src/utils/zip.ts +332 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-webdav-channel:WebDAV 通道(SyncTransport 的 webdav 实现)。
|
|
3
|
+
*
|
|
4
|
+
* 远端布局(单文件 JSON 快照 + 索引):
|
|
5
|
+
* <base>/dsh-config-manager/<id>.json —— 单个快照的完整载荷(SyncSnapshot 序列化)
|
|
6
|
+
* <base>/dsh-config-manager/index.json —— 索引(SyncSnapshotMeta 数组,每个 id 一条)
|
|
7
|
+
*
|
|
8
|
+
* 设计:
|
|
9
|
+
* - upload:先幂等 MKCOL snapshots 集合,再读 index 做「快照级跳过」判定
|
|
10
|
+
* (同 id 条目且 sections hash 全等 → 内容未变,跳过 PUT 直接返回远端 meta);
|
|
11
|
+
* 否则 PUT <id>.json(先快照文件)→ 合并(保留其它 id、覆盖同 id)→ PUT 写回 index
|
|
12
|
+
* (meta 最后落盘:快照文件成功后才写索引)。
|
|
13
|
+
* - list:GET index.json,缺失(404)视为空;按 createdAt 升序返回。
|
|
14
|
+
* - download:GET <id>.json 解析成 SyncSnapshot;不存在必须抛错(契约)。
|
|
15
|
+
* - delete:DELETE <id>.json 并从 index 摘除条目(写回合并后 index);文件不存在视为成功。
|
|
16
|
+
* - 二进制安全:快照序列化经 snapshot-json(文件分区 Uint8Array → base64 标记对象),
|
|
17
|
+
* JSON 往返字节无损(否则 JSON.stringify 把 TypedArray 变成数字索引对象,拉取还原
|
|
18
|
+
* 成普通对象 → Buffer.from(对象) 报错)。
|
|
19
|
+
* - 认证:HTTP Basic(username 配置项 + 注入 credentials 提供者 getPassword())。
|
|
20
|
+
* 密码绝不进 URL/日志;错误消息中的响应体统一脱敏(password → [REDACTED])。
|
|
21
|
+
* - 重定向:默认 request 自动跟随 301/302/303/307/308(网盘 WebDAV 会把下载 GET 302 到
|
|
22
|
+
* 带时效签名的 CDN 直链,如 123pan;不自建反代返回 301 也一样)。303 且非 GET/HEAD 时
|
|
23
|
+
* 降级为 GET 并丢弃请求体;跨源跳转剥离 Authorization(CDN 直链是预签名 URL,不得把
|
|
24
|
+
* Basic 凭据转发给第三方域);上限 5 跳,超出抛错。无 Location / Location 非法的 3xx
|
|
25
|
+
* 按最终响应返回(上层如实报 HTTP 状态失败)。
|
|
26
|
+
* - 可注入 request 便于测试;注入实现负责自己的重定向语义(默认实现才自动跟随)。
|
|
27
|
+
*/
|
|
28
|
+
import { requestOnce, type RawResponse } from '../../utils/proxy.ts';
|
|
29
|
+
import { zhMsg } from '../../core/messages.ts';
|
|
30
|
+
import type { MsgFunc } from '../../core/messages.ts';
|
|
31
|
+
import { deserializeSnapshot, serializeSnapshot } from '../snapshot-json.ts';
|
|
32
|
+
import { computeSnapshotMeta, sectionsEqual } from '../transport.ts';
|
|
33
|
+
import type { SyncSnapshot, SyncSnapshotMeta, SyncTransport } from '../transport.ts';
|
|
34
|
+
import { parseJsonSafe } from '../../utils/json.ts';
|
|
35
|
+
|
|
36
|
+
/** 快照 id 安全字符集:字母数字开头,仅 . _ -;防路径穿越与 URL 注入 */
|
|
37
|
+
const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
38
|
+
/** 保留 id:与 index.json 冲突(id 'index' 会占用索引文件路径) */
|
|
39
|
+
const RESERVED_IDS = new Set(['index']);
|
|
40
|
+
/**
|
|
41
|
+
* 默认单请求超时(ms)。WebDAV 上传大快照(含多个分区配置)与读写索引在慢速
|
|
42
|
+
* 服务器(如坚果云限速、自建 NAS)下较慢,30s 常不够 → 提高至 120s;
|
|
43
|
+
* 业务侧(makeSyncEngine)还会显式传 timeoutMs 覆盖默认值。
|
|
44
|
+
*/
|
|
45
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
46
|
+
/** 错误消息里截取的响应体最大长度(防超大/二进制响应撑爆消息) */
|
|
47
|
+
const ERR_BODY_MAX = 500;
|
|
48
|
+
const SNAPSHOTS_SEG = 'dsh-config-manager';
|
|
49
|
+
const INDEX_FILE = 'index.json';
|
|
50
|
+
const REDACTED = '[REDACTED]';
|
|
51
|
+
/** 自动跟随的重定向状态码(RFC 7231/9110) */
|
|
52
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
53
|
+
/** 重定向最大跳数(RFC 7231 建议 ≤5;防 302 循环拖死请求) */
|
|
54
|
+
const MAX_REDIRECTS = 5;
|
|
55
|
+
|
|
56
|
+
/** 请求选项:headers / body / 覆盖默认超时(ms;0 = 不超时) */
|
|
57
|
+
export interface WebDavRequestOptions {
|
|
58
|
+
headers?: Record<string, string>;
|
|
59
|
+
body?: string;
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 请求响应最小形状(兼容 fetch Response 的 status/ok/text();headers 供重定向读 Location) */
|
|
64
|
+
export interface WebDavResponse {
|
|
65
|
+
readonly status: number;
|
|
66
|
+
readonly ok: boolean;
|
|
67
|
+
/** 响应头(可选:注入的 mock 可省略,默认 request 总会带上,重定向跟随需要 location) */
|
|
68
|
+
readonly headers?: Record<string, string>;
|
|
69
|
+
text(): Promise<string>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 可注入的请求函数(测试 mock 用);method/url/options */
|
|
73
|
+
export type WebDavRequestFn = (
|
|
74
|
+
method: string,
|
|
75
|
+
url: string,
|
|
76
|
+
options?: WebDavRequestOptions,
|
|
77
|
+
) => Promise<WebDavResponse>;
|
|
78
|
+
|
|
79
|
+
/** 凭据提供者:password 只从这里读取,绝不落盘/进日志 */
|
|
80
|
+
export interface WebDavCredentialProvider {
|
|
81
|
+
getPassword(): Promise<string>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface WebDavTransportOptions {
|
|
85
|
+
/** WebDAV 远端根 URL(http/https),如 https://dav.example.com/dav/config */
|
|
86
|
+
baseUrl: string;
|
|
87
|
+
/** HTTP Basic 认证用户名 */
|
|
88
|
+
username: string;
|
|
89
|
+
/** 密码提供者(HTTP Basic 密码) */
|
|
90
|
+
credentials: WebDavCredentialProvider;
|
|
91
|
+
/** 可注入 request(测试 mock 用);缺省 = 全局 fetch + AbortController 超时 */
|
|
92
|
+
request?: WebDavRequestFn;
|
|
93
|
+
/** 单请求超时 ms,默认 120000(慢速 WebDAV 上传大快照需要宽裕窗口;0 = 不超时) */
|
|
94
|
+
timeoutMs?: number;
|
|
95
|
+
/** 消息翻译器(缺省 zh) */
|
|
96
|
+
msg?: MsgFunc;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class WebDavTransportError extends Error {
|
|
100
|
+
constructor(message: string) {
|
|
101
|
+
super(message);
|
|
102
|
+
this.name = 'WebDavTransportError';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 默认请求实现:node:https/http 原生流式请求(支持全部 WebDAV 方法、准确 Content-Length 与 User-Agent)。
|
|
107
|
+
* 自动跟随 301/302/303/307/308 重定向(网盘 WebDAV 的 GET/PUT 会 302 到 CDN 预签名直链):
|
|
108
|
+
* - 301/302/307/308 保持原方法与请求体;303 且非 GET/HEAD 降级为 GET 并丢弃请求体;
|
|
109
|
+
* - 相对 Location 用当前 URL 解析;跨源跳转剥离 Authorization(预签名 URL 不应收到 Basic 凭据);
|
|
110
|
+
* - 上限 MAX_REDIRECTS 跳,超出抛错;无 Location / Location 非法的 3xx 按最终响应返回。 */
|
|
111
|
+
const defaultRequest: WebDavRequestFn = async (method, url, options = {}) => {
|
|
112
|
+
let currentMethod = method;
|
|
113
|
+
let currentUrl = url;
|
|
114
|
+
let currentHeaders = { ...(options.headers ?? {}) };
|
|
115
|
+
let currentBody = options.body;
|
|
116
|
+
|
|
117
|
+
for (let redirects = 0; ; ) {
|
|
118
|
+
const res = await rawRequest(currentMethod, currentUrl, {
|
|
119
|
+
...options,
|
|
120
|
+
headers: currentHeaders,
|
|
121
|
+
body: currentBody,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const status = res.status;
|
|
125
|
+
if (!REDIRECT_STATUSES.has(status)) return res;
|
|
126
|
+
|
|
127
|
+
// 3xx 但没有 Location(或 Location 非法)→ 无法跟随,作为最终响应返回(上层如实报 HTTP 状态)
|
|
128
|
+
const rawLocation = res.headers?.location;
|
|
129
|
+
const location = Array.isArray(rawLocation) ? rawLocation[0] : rawLocation;
|
|
130
|
+
if (!location) return res;
|
|
131
|
+
let nextUrl: string;
|
|
132
|
+
try {
|
|
133
|
+
nextUrl = new URL(location, currentUrl).toString();
|
|
134
|
+
} catch {
|
|
135
|
+
return res;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (redirects >= MAX_REDIRECTS) {
|
|
139
|
+
const err = new Error(`Too many redirects (${MAX_REDIRECTS} max) for ${url}`);
|
|
140
|
+
err.name = 'RedirectError';
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
redirects += 1;
|
|
144
|
+
|
|
145
|
+
// 303:非 GET/HEAD 降级 GET 并丢弃请求体(303 See Other 语义)
|
|
146
|
+
let nextMethod = currentMethod;
|
|
147
|
+
let nextHeaders = currentHeaders;
|
|
148
|
+
let nextBody = currentBody;
|
|
149
|
+
if (status === 303 && currentMethod !== 'GET' && currentMethod !== 'HEAD') {
|
|
150
|
+
nextMethod = 'GET';
|
|
151
|
+
nextBody = undefined;
|
|
152
|
+
}
|
|
153
|
+
// 跨源:剥离 Authorization(同源保留,服务器可能按 Basic 认证续用)
|
|
154
|
+
if (!sameOrigin(currentUrl, nextUrl)) {
|
|
155
|
+
nextHeaders = { ...currentHeaders };
|
|
156
|
+
delete nextHeaders.Authorization;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
currentMethod = nextMethod;
|
|
160
|
+
currentUrl = nextUrl;
|
|
161
|
+
currentHeaders = nextHeaders;
|
|
162
|
+
currentBody = nextBody;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** 单次裸请求(不跟随重定向)。
|
|
167
|
+
*
|
|
168
|
+
* 实现已统一到 `utils/proxy.requestOnce`(issue #30 ②级方案):同一份实现同时服务 WebDAV 与
|
|
169
|
+
* GitHub 出站,并在此处获得**插件私有**的代理能力(HTTP 代理 absolute-form / HTTPS 代理 CONNECT
|
|
170
|
+
* 隧道 + TLS),且不改动任何全局状态。未配置代理时行为与原先的 node:http 直连完全一致。
|
|
171
|
+
*/
|
|
172
|
+
async function rawRequest(
|
|
173
|
+
method: string,
|
|
174
|
+
url: string,
|
|
175
|
+
options: WebDavRequestOptions,
|
|
176
|
+
): Promise<WebDavResponse> {
|
|
177
|
+
const timeoutMs = options.timeoutMs ?? 0;
|
|
178
|
+
const headers: Record<string, string> = {
|
|
179
|
+
'User-Agent': 'DSH-Config-Manager/0.1.55 (WebDAV Client)',
|
|
180
|
+
...(options.headers ?? {}),
|
|
181
|
+
};
|
|
182
|
+
if (options.body === undefined) {
|
|
183
|
+
// 303 降级后已丢弃 body → 清除残留的 Content-Length,避免 GET 带错误长度头
|
|
184
|
+
delete headers['Content-Length'];
|
|
185
|
+
delete headers['content-length'];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let res: RawResponse;
|
|
189
|
+
try {
|
|
190
|
+
res = await requestOnce({
|
|
191
|
+
method,
|
|
192
|
+
url,
|
|
193
|
+
headers,
|
|
194
|
+
...(options.body !== undefined ? { body: options.body } : {}),
|
|
195
|
+
...(timeoutMs > 0 ? { timeoutMs } : {}),
|
|
196
|
+
});
|
|
197
|
+
} catch (err) {
|
|
198
|
+
// 超时错误名保持 'TimeoutError'(上层 isTimeout 依赖它做文案归一)
|
|
199
|
+
if (err instanceof Error && /timed out/i.test(err.message) && err.name !== 'TimeoutError') {
|
|
200
|
+
const timeoutError = new Error(err.message);
|
|
201
|
+
timeoutError.name = 'TimeoutError';
|
|
202
|
+
throw timeoutError;
|
|
203
|
+
}
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const responseHeaders: Record<string, string> = {};
|
|
208
|
+
res.headers.forEach((value, key) => { responseHeaders[key] = value });
|
|
209
|
+
return {
|
|
210
|
+
status: res.status,
|
|
211
|
+
ok: res.status >= 200 && res.status < 300,
|
|
212
|
+
headers: responseHeaders,
|
|
213
|
+
text: async () => res.body.toString('utf8'),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 同源判断(协议 + host,host 含端口;子域名不同视为跨源) */
|
|
218
|
+
function sameOrigin(a: string, b: string): boolean {
|
|
219
|
+
try {
|
|
220
|
+
const ua = new URL(a);
|
|
221
|
+
const ub = new URL(b);
|
|
222
|
+
return ua.protocol === ub.protocol && ua.host === ub.host;
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 实现 SyncTransport 的 WebDAV 通道。所有网络操作经 request 注入(缺省 defaultRequest,自动跟随重定向)。 */
|
|
229
|
+
export class WebDavTransport implements SyncTransport {
|
|
230
|
+
readonly type = 'webdav';
|
|
231
|
+
|
|
232
|
+
private readonly o: {
|
|
233
|
+
baseUrl: string;
|
|
234
|
+
username: string;
|
|
235
|
+
credentials: WebDavCredentialProvider;
|
|
236
|
+
request: WebDavRequestFn;
|
|
237
|
+
timeoutMs: number;
|
|
238
|
+
msg: MsgFunc;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
constructor(options: WebDavTransportOptions) {
|
|
242
|
+
this.o = {
|
|
243
|
+
baseUrl: '',
|
|
244
|
+
username: '',
|
|
245
|
+
credentials: { getPassword: async () => '' },
|
|
246
|
+
request: defaultRequest,
|
|
247
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
248
|
+
msg: zhMsg,
|
|
249
|
+
};
|
|
250
|
+
this.validateOptions(options);
|
|
251
|
+
this.o.baseUrl = options.baseUrl;
|
|
252
|
+
this.o.username = options.username;
|
|
253
|
+
this.o.credentials = options.credentials;
|
|
254
|
+
if (options.request !== undefined) this.o.request = options.request;
|
|
255
|
+
if (options.timeoutMs !== undefined) this.o.timeoutMs = options.timeoutMs;
|
|
256
|
+
if (options.msg !== undefined) this.o.msg = options.msg;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 列出远端已有快照(按 createdAt 升序)。index.json 缺失视为空。 */
|
|
260
|
+
async list(): Promise<SyncSnapshotMeta[]> {
|
|
261
|
+
const pwd = await this.passwordOnce();
|
|
262
|
+
const url = this.indexUrl();
|
|
263
|
+
const res = await this.send('GET', url, pwd);
|
|
264
|
+
if (res.status === 404) return []; // 缺失视为空
|
|
265
|
+
if (!res.ok) {
|
|
266
|
+
throw new WebDavTransportError(await this.failText('GET', url, res, pwd));
|
|
267
|
+
}
|
|
268
|
+
return this.parseIndex(await res.text(), url, pwd);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 上传快照:幂等 MKCOL → 快照级跳过判定(同 id 且内容全等则免上传)→ PUT <id>.json
|
|
272
|
+
* → 合并写回 index.json;返回远端 meta(跳过时)或 computeSnapshotMeta。
|
|
273
|
+
* 序列化经 snapshot-json:文件类分区字节以 base64 传输(JSON 无法直传 Uint8Array)。 */
|
|
274
|
+
async upload(snapshot: SyncSnapshot): Promise<SyncSnapshotMeta> {
|
|
275
|
+
this.assertSafeId(snapshot.id);
|
|
276
|
+
const pwd = await this.passwordOnce();
|
|
277
|
+
await this.ensureCollection(pwd);
|
|
278
|
+
// 快照级跳过(增量优化):先读远端 index,同 id 条目与本地内容全等(sections hash 全等)
|
|
279
|
+
// → 内容未变,直接返回远端 meta,跳过 PUT 快照文件与 index 写回(幂等契约不变)。
|
|
280
|
+
// 加密快照(computeSnapshotMeta.sections 为空对象)经 sectionsEqual 判定为「无法比较」
|
|
281
|
+
// → 必须照常上传,绝不跳过。
|
|
282
|
+
const meta = computeSnapshotMeta(snapshot);
|
|
283
|
+
const idxBefore = await this.readIndex(pwd);
|
|
284
|
+
const existing = idxBefore.find((m) => m.id === snapshot.id);
|
|
285
|
+
if (existing !== undefined && sectionsEqual(existing, meta)) {
|
|
286
|
+
return existing;
|
|
287
|
+
}
|
|
288
|
+
// 先写快照文件(JSON 序列化:文件字节 base64 编码,往返无损)
|
|
289
|
+
const snapUrl = this.snapshotUrl(snapshot.id);
|
|
290
|
+
const snapRes = await this.send('PUT', snapUrl, pwd, { body: serializeSnapshot(snapshot) });
|
|
291
|
+
if (!snapRes.ok) {
|
|
292
|
+
throw new WebDavTransportError(await this.failText('PUT', snapUrl, snapRes, pwd));
|
|
293
|
+
}
|
|
294
|
+
// 再写合并后的 index(保留其它 id、覆盖同 id)—— meta 最后落盘
|
|
295
|
+
const idxUrl = this.indexUrl();
|
|
296
|
+
const merged = idxBefore.filter((m) => m.id !== snapshot.id);
|
|
297
|
+
merged.push(meta);
|
|
298
|
+
merged.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0));
|
|
299
|
+
const putIdx = await this.send('PUT', idxUrl, pwd, { body: JSON.stringify(merged) });
|
|
300
|
+
if (!putIdx.ok) {
|
|
301
|
+
throw new WebDavTransportError(await this.failText('PUT', idxUrl, putIdx, pwd));
|
|
302
|
+
}
|
|
303
|
+
return meta;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 下载快照完整载荷。不存在的 id 必须抛错(契约)。 */
|
|
307
|
+
async download(id: string): Promise<SyncSnapshot> {
|
|
308
|
+
this.assertSafeId(id);
|
|
309
|
+
const pwd = await this.passwordOnce();
|
|
310
|
+
const url = this.snapshotUrl(id);
|
|
311
|
+
const res = await this.send('GET', url, pwd);
|
|
312
|
+
if (res.status === 404) {
|
|
313
|
+
throw new WebDavTransportError(this.o.msg('sync.webdav.snapshotMissing', { id, url }));
|
|
314
|
+
}
|
|
315
|
+
if (!res.ok) {
|
|
316
|
+
throw new WebDavTransportError(await this.failText('GET', url, res, pwd));
|
|
317
|
+
}
|
|
318
|
+
return this.parseSnapshot(await res.text(), id);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** 删除远端快照并从 index 摘除(写回合并后的 index);文件不存在视为成功。 */
|
|
322
|
+
async delete(id: string): Promise<void> {
|
|
323
|
+
this.assertSafeId(id);
|
|
324
|
+
const pwd = await this.passwordOnce();
|
|
325
|
+
const idxUrl = this.indexUrl();
|
|
326
|
+
// 先读现有 index(不存在 → 空),以便摘除条目
|
|
327
|
+
let idx: SyncSnapshotMeta[] = [];
|
|
328
|
+
try {
|
|
329
|
+
idx = await this.readIndex(pwd);
|
|
330
|
+
} catch {
|
|
331
|
+
idx = []; // index 缺失/损坏时按无条目处理(不阻塞删除)
|
|
332
|
+
}
|
|
333
|
+
// delete 快照文件:404 = 不存在,视为成功
|
|
334
|
+
const url = this.snapshotUrl(id);
|
|
335
|
+
const res = await this.send('DELETE', url, pwd);
|
|
336
|
+
if (!res.ok && res.status !== 404) {
|
|
337
|
+
throw new WebDavTransportError(await this.failText('DELETE', url, res, pwd));
|
|
338
|
+
}
|
|
339
|
+
// 若 index 中无该 id,则无需写回
|
|
340
|
+
const remaining = idx.filter((m) => m.id !== id);
|
|
341
|
+
if (remaining.length === idx.length && res.status === 404) {
|
|
342
|
+
return; // 文件与 index 都不存在 → 静默成功,无写回
|
|
343
|
+
}
|
|
344
|
+
const putIdx = await this.send('PUT', idxUrl, pwd, { body: JSON.stringify(remaining) });
|
|
345
|
+
if (!putIdx.ok) {
|
|
346
|
+
throw new WebDavTransportError(await this.failText('PUT', idxUrl, putIdx, pwd));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* ---------------- 内部实现 ---------------- */
|
|
351
|
+
|
|
352
|
+
private validateOptions(options: WebDavTransportOptions): void {
|
|
353
|
+
const msg = options.msg ?? zhMsg;
|
|
354
|
+
if (typeof options.baseUrl !== 'string' || options.baseUrl.trim() === '') {
|
|
355
|
+
throw new WebDavTransportError(msg('sync.webdav.baseUrlRequired'));
|
|
356
|
+
}
|
|
357
|
+
let parsed: URL;
|
|
358
|
+
try {
|
|
359
|
+
parsed = new URL(options.baseUrl);
|
|
360
|
+
} catch {
|
|
361
|
+
throw new WebDavTransportError(msg('sync.webdav.baseUrlInvalid', { url: options.baseUrl }));
|
|
362
|
+
}
|
|
363
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
364
|
+
throw new WebDavTransportError(msg('sync.webdav.baseUrlInvalid', { url: options.baseUrl }));
|
|
365
|
+
}
|
|
366
|
+
if (parsed.username !== '' || parsed.password !== '') {
|
|
367
|
+
throw new WebDavTransportError(msg('sync.webdav.baseUrlUserinfo'));
|
|
368
|
+
}
|
|
369
|
+
if (typeof options.username !== 'string' || options.username === '') {
|
|
370
|
+
throw new WebDavTransportError(msg('sync.webdav.usernameRequired'));
|
|
371
|
+
}
|
|
372
|
+
if (options.credentials === null || typeof options.credentials !== 'object'
|
|
373
|
+
|| typeof options.credentials.getPassword !== 'function') {
|
|
374
|
+
throw new WebDavTransportError(msg('sync.webdav.credentialsRequired'));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private assertSafeId(id: string): void {
|
|
379
|
+
if (typeof id !== 'string' || !SAFE_ID_RE.test(id) || RESERVED_IDS.has(id)) {
|
|
380
|
+
throw new WebDavTransportError(this.o.msg('sync.webdav.invalidSnapshotId', { id: JSON.stringify(id) }));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private snapshotsBase(): string {
|
|
385
|
+
return `${this.o.baseUrl.replace(/\/+$/, '')}/${SNAPSHOTS_SEG}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private snapshotsColUrl(): string {
|
|
389
|
+
return `${this.snapshotsBase()}/`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private snapshotUrl(id: string): string {
|
|
393
|
+
return `${this.snapshotsBase()}/${id}.json`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private indexUrl(): string {
|
|
397
|
+
return `${this.snapshotsBase()}/${INDEX_FILE}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 读取一次 password(错误脱敏用) */
|
|
401
|
+
private async passwordOnce(): Promise<string> {
|
|
402
|
+
try {
|
|
403
|
+
return await this.o.credentials.getPassword();
|
|
404
|
+
} catch {
|
|
405
|
+
return '';
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** 幂等创建 snapshots 集合:MKCOL;已存在(405/2xx/3xx)视为成功。 */
|
|
410
|
+
private async ensureCollection(pwd: string): Promise<void> {
|
|
411
|
+
const url = this.snapshotsColUrl();
|
|
412
|
+
const res = await this.send('MKCOL', url, pwd);
|
|
413
|
+
const okStatuses = new Set([200, 201, 204, 301, 302, 303, 405]);
|
|
414
|
+
if (okStatuses.has(res.status)) return;
|
|
415
|
+
throw new WebDavTransportError(await this.failText('MKCOL', url, res, pwd));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** 读 index(缺失 → [];非法 → 抛错)。 */
|
|
419
|
+
private async readIndex(pwd: string): Promise<SyncSnapshotMeta[]> {
|
|
420
|
+
const url = this.indexUrl();
|
|
421
|
+
const res = await this.send('GET', url, pwd);
|
|
422
|
+
if (res.status === 404) return [];
|
|
423
|
+
if (!res.ok) {
|
|
424
|
+
throw new WebDavTransportError(await this.failText('GET', url, res, pwd));
|
|
425
|
+
}
|
|
426
|
+
return this.parseIndex(await res.text(), url, pwd);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private parseIndex(raw: string, url: string, pwd: string): SyncSnapshotMeta[] {
|
|
430
|
+
let parsed: unknown;
|
|
431
|
+
try {
|
|
432
|
+
parsed = parseJsonSafe(raw);
|
|
433
|
+
} catch (err) {
|
|
434
|
+
throw new WebDavTransportError(
|
|
435
|
+
this.o.msg('sync.webdav.indexInvalid', { url, err: this.mask(String((err as Error)?.message ?? ''), pwd) }),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (!Array.isArray(parsed)) {
|
|
439
|
+
throw new WebDavTransportError(this.o.msg('sync.webdav.indexInvalid', { url, err: 'not an array' }));
|
|
440
|
+
}
|
|
441
|
+
const valid = (m: unknown): m is SyncSnapshotMeta =>
|
|
442
|
+
typeof m === 'object' && m !== null
|
|
443
|
+
&& typeof (m as SyncSnapshotMeta).id === 'string'
|
|
444
|
+
&& typeof (m as SyncSnapshotMeta).createdAt === 'string'
|
|
445
|
+
&& typeof (m as SyncSnapshotMeta).manifest === 'object' && (m as SyncSnapshotMeta).manifest !== null
|
|
446
|
+
&& typeof (m as SyncSnapshotMeta).sections === 'object' && (m as SyncSnapshotMeta).sections !== null;
|
|
447
|
+
if (!parsed.every(valid)) {
|
|
448
|
+
throw new WebDavTransportError(this.o.msg('sync.webdav.indexInvalid', { url, err: 'invalid entry' }));
|
|
449
|
+
}
|
|
450
|
+
const metas = parsed as SyncSnapshotMeta[];
|
|
451
|
+
metas.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0));
|
|
452
|
+
return metas;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private parseSnapshot(raw: string, id: string): SyncSnapshot {
|
|
456
|
+
try {
|
|
457
|
+
return deserializeSnapshot(raw);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
throw new WebDavTransportError(
|
|
460
|
+
this.o.msg('sync.webdav.snapshotInvalid', { id, err: this.mask(String((err as Error)?.message ?? ''), '') }),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** 发送请求:注入 Basic 认证头 + 调用 request;网络错误/超时归一 */
|
|
466
|
+
private async send(
|
|
467
|
+
method: string,
|
|
468
|
+
url: string,
|
|
469
|
+
pwd: string,
|
|
470
|
+
opts: { body?: string } = {},
|
|
471
|
+
): Promise<WebDavResponse> {
|
|
472
|
+
const auth = 'Basic ' + Buffer.from(`${this.o.username}:${pwd}`, 'utf8').toString('base64');
|
|
473
|
+
const headers: Record<string, string> = {
|
|
474
|
+
Authorization: auth,
|
|
475
|
+
'Content-Type': 'application/json',
|
|
476
|
+
'User-Agent': 'DSH-Config-Manager/0.1.55 (WebDAV Client)',
|
|
477
|
+
};
|
|
478
|
+
if (opts.body !== undefined) headers['Content-Length'] = String(Buffer.byteLength(opts.body, 'utf8'));
|
|
479
|
+
try {
|
|
480
|
+
return await this.o.request(method, url, { headers, body: opts.body, timeoutMs: this.o.timeoutMs });
|
|
481
|
+
} catch (err) {
|
|
482
|
+
if (this.isTimeout(err)) {
|
|
483
|
+
throw new WebDavTransportError(
|
|
484
|
+
this.o.msg('sync.webdav.timeout', { method, url, timeout: String(this.o.timeoutMs) }),
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
if (err instanceof Error && err.name === 'RedirectError') {
|
|
488
|
+
// 默认 request 的重定向跳数超限(如 302 循环)→ 归一为清晰消息,避免暴露内部跳转详情
|
|
489
|
+
throw new WebDavTransportError(
|
|
490
|
+
this.o.msg('sync.webdav.tooManyRedirects', { method, url, n: String(MAX_REDIRECTS) }),
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
const rawMsg = err instanceof Error
|
|
494
|
+
? (err.cause ? `${err.message} (${(err.cause as Error).message || err.cause})` : err.message)
|
|
495
|
+
: String(err);
|
|
496
|
+
throw new WebDavTransportError(
|
|
497
|
+
this.o.msg('sync.webdav.requestError', { method, url, err: this.mask(rawMsg, pwd) }),
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** 构造 HTTP 非 2xx 失败消息:附上脱敏后的响应体片段(截断) */
|
|
503
|
+
private async failText(method: string, url: string, res: WebDavResponse, pwd: string): Promise<string> {
|
|
504
|
+
let body = '';
|
|
505
|
+
try {
|
|
506
|
+
body = (await res.text()).slice(0, ERR_BODY_MAX);
|
|
507
|
+
} catch {
|
|
508
|
+
body = '';
|
|
509
|
+
}
|
|
510
|
+
return this.o.msg('sync.webdav.requestFailed', {
|
|
511
|
+
method,
|
|
512
|
+
url,
|
|
513
|
+
status: String(res.status),
|
|
514
|
+
err: this.mask(body, pwd),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** 识别超时错误(AbortController 抛出的 AbortError / DOMException timeout / TimeoutError) */
|
|
519
|
+
private isTimeout(err: unknown): boolean {
|
|
520
|
+
const e = err as Error | undefined;
|
|
521
|
+
const name = e?.name ?? '';
|
|
522
|
+
if (name === 'TimeoutError') return true;
|
|
523
|
+
if (name === 'AbortError') return true;
|
|
524
|
+
if (typeof DOMException !== 'undefined' && err instanceof DOMException && name === 'AbortError') return true;
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** 错误消息脱敏:password(原文与 URL 编码形态)一律替换 */
|
|
529
|
+
private mask(text: string, pwd: string): string {
|
|
530
|
+
if (!pwd) return text;
|
|
531
|
+
let out = text.split(pwd).join(REDACTED);
|
|
532
|
+
out = out.split(encodeURIComponent(pwd)).join(REDACTED);
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* errors 测试(m6-ui,规范 §23):可操作错误 Reason/Suggested action,Secret 脱敏。
|
|
3
|
+
*/
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { formatActionableError, toActionableError } from './errors.ts';
|
|
7
|
+
import { makeUiT } from './i18n.ts';
|
|
8
|
+
|
|
9
|
+
const enT = makeUiT('en');
|
|
10
|
+
|
|
11
|
+
test('errors: SETTINGS_CONFLICT 映射为可操作建议', () => {
|
|
12
|
+
const e = toActionableError(new Error('SETTINGS_CONFLICT: llm-deepseek revision 12 != 14'));
|
|
13
|
+
assert.equal(e.title, '配置已被并发修改');
|
|
14
|
+
assert.ok(e.suggestedAction !== undefined);
|
|
15
|
+
assert.equal(e.retryable, true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('errors: 完整性失败不可重试', () => {
|
|
19
|
+
const e = toActionableError(new Error('备份完整性校验失败: "config/settings.json"'));
|
|
20
|
+
assert.equal(e.title, '备份完整性校验失败');
|
|
21
|
+
assert.equal(e.retryable, false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('errors: 未知错误回退通用文案(仍给 Reason)', () => {
|
|
25
|
+
const e = toActionableError(new Error('boom'));
|
|
26
|
+
assert.equal(e.title, '操作失败');
|
|
27
|
+
assert.equal(e.reason, 'boom');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('errors: Secret 值绝不进入输出(脱敏)', () => {
|
|
31
|
+
const e = toActionableError(new Error('写入失败: apiKey=sk-abcdef1234567890'));
|
|
32
|
+
assert.ok(!e.reason.includes('sk-abcdef1234567890'));
|
|
33
|
+
assert.ok(e.reason.includes('REDACTED'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('errors: 带 item 的错误也脱敏', () => {
|
|
37
|
+
const e = toActionableError(new Error('fail'), { item: 'apiKey=sk-zzz999' });
|
|
38
|
+
assert.ok(!e.item!.includes('sk-zzz999'));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('errors: formatActionableError 输出 Reason + Suggested action(en)', () => {
|
|
42
|
+
const e = toActionableError(new Error('ENOENT: no such file'), { item: 'x.zip', t: enT });
|
|
43
|
+
const text = formatActionableError(e, enT);
|
|
44
|
+
assert.ok(text.includes('Reason:'));
|
|
45
|
+
assert.ok(text.includes('Suggested action:'));
|
|
46
|
+
assert.ok(text.includes('Item:'));
|
|
47
|
+
});
|
package/src/ui/errors.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 可操作错误模型(规范 §23,m6-ui)。
|
|
3
|
+
*
|
|
4
|
+
* 绝不只有 "Something went wrong.":
|
|
5
|
+
* - 解析错误 → Reason + Suggested action(可操作);
|
|
6
|
+
* - 输出前强制脱敏(redact),Secret 永不进入 UI/日志;
|
|
7
|
+
* - 文案经 UiT 注入(zh 源 / en 镜像,见 i18n.ts)。
|
|
8
|
+
*/
|
|
9
|
+
import { redact } from '../security/redaction.ts';
|
|
10
|
+
import { zhUiT, type UiT } from './i18n.ts';
|
|
11
|
+
|
|
12
|
+
/** 可操作错误(UI 渲染为 Reason / Suggested action 两段) */
|
|
13
|
+
export interface ActionableError {
|
|
14
|
+
title: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
suggestedAction?: string;
|
|
17
|
+
/** 相关项(如 MCP 服务器名 / 插件包名);可空 */
|
|
18
|
+
item?: string;
|
|
19
|
+
/** 是否可重试(true 显示重试按钮) */
|
|
20
|
+
retryable: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 常见错误 → 建议动作 的规则表(按错误消息子串匹配) */
|
|
24
|
+
interface ErrorRule {
|
|
25
|
+
match: (msg: string) => boolean;
|
|
26
|
+
title: string;
|
|
27
|
+
suggestedAction: string;
|
|
28
|
+
retryable: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildErrorRules(t: UiT): readonly ErrorRule[] {
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
match: (m) => /SETTINGS_CONFLICT|revision.*conflict/i.test(m),
|
|
35
|
+
title: t('error.settingsConflict.title'),
|
|
36
|
+
suggestedAction: t('error.settingsConflict.action'),
|
|
37
|
+
retryable: true,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
match: (m) => /ImportNotConfirmed|未确认/i.test(m),
|
|
41
|
+
title: t('error.notConfirmed.title'),
|
|
42
|
+
suggestedAction: t('error.notConfirmed.action'),
|
|
43
|
+
retryable: true,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
match: (m) => /backup integrity|完整性校验失败|checksum/i.test(m),
|
|
47
|
+
title: t('error.integrity.title'),
|
|
48
|
+
suggestedAction: t('error.integrity.action'),
|
|
49
|
+
retryable: false,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
match: (m) => /schema.*(不支持|超出)|无法导入|Unsupported|schemaUnsupported|无法导入/i.test(m),
|
|
53
|
+
title: t('error.schema.title'),
|
|
54
|
+
suggestedAction: t('error.schema.action'),
|
|
55
|
+
retryable: false,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
match: (m) => /ENOENT|not found|无法读取|不存在|readFailed/i.test(m),
|
|
59
|
+
title: t('error.notFound.title'),
|
|
60
|
+
suggestedAction: t('error.notFound.action'),
|
|
61
|
+
retryable: true,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
match: (m) => /EACCES|permission|权限/i.test(m),
|
|
65
|
+
title: t('error.permission.title'),
|
|
66
|
+
suggestedAction: t('error.permission.action'),
|
|
67
|
+
retryable: true,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
match: (m) => /install.*(plugin|插件)|needsRestart|重启/i.test(m),
|
|
71
|
+
title: t('error.needsRestart.title'),
|
|
72
|
+
suggestedAction: t('error.needsRestart.action'),
|
|
73
|
+
retryable: false,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 将任意错误转换为可操作错误(消息已脱敏,不泄漏 Secret) */
|
|
79
|
+
export function toActionableError(err: unknown, opts?: { item?: string; fallbackTitle?: string; t?: UiT }): ActionableError {
|
|
80
|
+
const t = opts?.t ?? zhUiT;
|
|
81
|
+
const raw = err instanceof Error ? err.message : String(err ?? t('commonUnknownError'));
|
|
82
|
+
const message = redact(raw);
|
|
83
|
+
const rule = buildErrorRules(t).find((r) => r.match(message));
|
|
84
|
+
return {
|
|
85
|
+
title: rule?.title ?? opts?.fallbackTitle ?? t('error.fallback'),
|
|
86
|
+
reason: message,
|
|
87
|
+
suggestedAction: rule?.suggestedAction,
|
|
88
|
+
item: opts?.item !== undefined ? redact(opts.item) : undefined,
|
|
89
|
+
retryable: rule?.retryable ?? true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 格式化为用户可读的多行文本(Reason / Suggested action) */
|
|
94
|
+
export function formatActionableError(e: ActionableError, t: UiT = zhUiT): string {
|
|
95
|
+
const lines = [e.title];
|
|
96
|
+
lines.push(`${t('error.reason')}: ${e.reason}`);
|
|
97
|
+
if (e.suggestedAction) lines.push(`${t('error.suggestedAction')}: ${e.suggestedAction}`);
|
|
98
|
+
if (e.item) lines.push(`${t('error.item')}: ${e.item}`);
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|