dsh-config-manager 0.1.78 → 0.1.80
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/lib/adapters/credentials.d.ts +6 -2
- package/lib/adapters/credentials.js +19 -3
- package/lib/adapters/file-collection.js +2 -1
- package/lib/adapters/mcp.d.ts +48 -2
- package/lib/adapters/mcp.js +265 -3
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-files.js +2 -1
- package/lib/adapters/plugins.js +105 -20
- package/lib/adapters/pnpm-workspace.d.ts +32 -0
- package/lib/adapters/pnpm-workspace.js +113 -2
- package/lib/adapters/prompts.d.ts +6 -2
- package/lib/adapters/prompts.js +17 -2
- package/lib/adapters/providers.d.ts +19 -2
- package/lib/adapters/providers.js +59 -3
- package/lib/adapters/settings.d.ts +6 -2
- package/lib/adapters/settings.js +13 -2
- package/lib/adapters/skills.d.ts +25 -1
- package/lib/adapters/skills.js +60 -0
- package/lib/adapters/ui.d.ts +6 -2
- package/lib/adapters/ui.js +13 -2
- package/lib/adapters/units.d.ts +18 -0
- package/lib/adapters/units.js +39 -0
- package/lib/adapters/workspaces.js +2 -1
- package/lib/client.d.ts +401 -112
- package/lib/client.js +2899 -1525
- package/lib/core/backup.js +20 -0
- package/lib/core/mcp-connector-store.d.ts +159 -0
- package/lib/core/mcp-connector-store.js +445 -0
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +20 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +18 -1
- package/lib/core/plugin-cli.d.ts +22 -2
- package/lib/core/plugin-cli.js +22 -3
- package/lib/core/recovery-orchestrator.d.ts +3 -1
- package/lib/core/recovery-orchestrator.js +75 -16
- package/lib/core/rollback-activity.d.ts +89 -0
- package/lib/core/rollback-activity.js +350 -0
- package/lib/core/rollback-ledger.d.ts +0 -9
- package/lib/core/rollback-ledger.js +37 -0
- package/lib/core/rollback-merge.d.ts +22 -2
- package/lib/core/rollback-merge.js +63 -13
- package/lib/core/rollback-point.d.ts +93 -0
- package/lib/core/rollback-point.js +420 -101
- package/lib/core/rollback-undo-journal.d.ts +123 -0
- package/lib/core/rollback-undo-journal.js +517 -0
- package/lib/core/self-update.js +2 -4
- package/lib/core/text-fidelity.d.ts +89 -0
- package/lib/core/text-fidelity.js +176 -0
- package/lib/core/types.d.ts +12 -3
- package/lib/core/update-check.js +2 -4
- package/lib/core/validator.d.ts +9 -0
- package/lib/core/validator.js +12 -0
- package/lib/foreign/bundle.js +42 -3
- package/lib/foreign/claude-code.js +358 -26
- package/lib/foreign/claude-sessions.d.ts +51 -8
- package/lib/foreign/claude-sessions.js +111 -13
- package/lib/foreign/read-claude-code.d.ts +33 -4
- package/lib/foreign/read-claude-code.js +446 -65
- package/lib/foreign/read-claude-plugins.d.ts +36 -0
- package/lib/foreign/read-claude-plugins.js +403 -0
- package/lib/foreign/registry.js +25 -2
- package/lib/foreign/truth-table.js +6 -4
- package/lib/foreign/types.d.ts +170 -2
- package/lib/index.d.ts +14 -5
- package/lib/index.js +60 -16
- package/lib/market/git-file-writer.js +7 -1
- package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
- package/lib/profiles/dsh-profile-launcher.js +58 -1
- package/lib/profiles/dsh-profile-shared.d.ts +8 -2
- package/lib/profiles/dsh-profile-shared.js +8 -2
- package/lib/routes/foreign.js +8 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/sync.js +173 -9
- package/lib/schema/manifest.d.ts +6 -1
- package/lib/schema/manifest.js +55 -0
- package/lib/schema/types.d.ts +47 -2
- package/lib/sync/ancestor.d.ts +22 -0
- package/lib/sync/ancestor.js +66 -2
- package/lib/sync/autosync-config.d.ts +73 -0
- package/lib/sync/autosync-config.js +89 -0
- package/lib/sync/autosync-scheduler.d.ts +80 -6
- package/lib/sync/autosync-scheduler.js +263 -40
- package/lib/sync/gist/gist-transport.d.ts +18 -1
- package/lib/sync/gist/gist-transport.js +42 -3
- package/lib/sync/gist/messages.d.ts +2 -0
- package/lib/sync/gist/messages.js +5 -0
- package/lib/sync/git/git-transport.d.ts +42 -3
- package/lib/sync/git/git-transport.js +146 -3
- package/lib/sync/merge.d.ts +47 -14
- package/lib/sync/merge.js +242 -222
- package/lib/sync/messages.d.ts +8 -0
- package/lib/sync/messages.js +19 -0
- package/lib/sync/risk.d.ts +19 -2
- package/lib/sync/risk.js +10 -3
- package/lib/sync/s3/messages.d.ts +6 -0
- package/lib/sync/s3/messages.js +13 -0
- package/lib/sync/s3/s3-transport.d.ts +9 -1
- package/lib/sync/s3/s3-transport.js +78 -6
- package/lib/sync/s3/sigv4.js +11 -2
- package/lib/sync/sync-engine.d.ts +212 -8
- package/lib/sync/sync-engine.js +514 -58
- package/lib/sync/sync-history.d.ts +11 -0
- package/lib/sync/sync-history.js +20 -0
- package/lib/sync/sync-state.d.ts +43 -0
- package/lib/sync/sync-state.js +44 -0
- package/lib/sync/sync-watcher.d.ts +76 -0
- package/lib/sync/sync-watcher.js +202 -0
- package/lib/sync/text-merge.d.ts +85 -0
- package/lib/sync/text-merge.js +513 -0
- package/lib/sync/transport.d.ts +48 -0
- package/lib/sync/transport.js +12 -0
- package/lib/sync/watch-scope.d.ts +38 -0
- package/lib/sync/watch-scope.js +95 -0
- package/lib/sync/webdav/webdav-transport.d.ts +16 -2
- package/lib/sync/webdav/webdav-transport.js +128 -11
- package/lib/ui/foreign-view.js +10 -0
- package/lib/ui/i18n.d.ts +9 -0
- package/lib/ui/i18n.js +22 -0
- package/lib/ui/rollback-view.d.ts +12 -0
- package/lib/ui/rollback-view.js +2 -0
- package/lib/ui/sync-settings-view.d.ts +47 -0
- package/lib/ui/sync-settings-view.js +24 -0
- package/lib/ui/types.d.ts +21 -0
- package/lib/utils/env-lock.d.ts +1 -0
- package/lib/utils/env-lock.js +7 -4
- package/package.json +6 -6
- package/src/adapters/credentials.ts +22 -4
- package/src/adapters/file-collection.ts +2 -1
- package/src/adapters/mcp.test.ts +235 -1
- package/src/adapters/mcp.ts +276 -5
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-files.ts +2 -1
- package/src/adapters/plugins.test.ts +176 -2
- package/src/adapters/plugins.ts +107 -21
- package/src/adapters/pnpm-workspace.test.ts +89 -1
- package/src/adapters/pnpm-workspace.ts +127 -2
- package/src/adapters/prompts.ts +20 -3
- package/src/adapters/providers.ts +62 -4
- package/src/adapters/settings.ts +16 -3
- package/src/adapters/skills.test.ts +141 -0
- package/src/adapters/skills.ts +58 -2
- package/src/adapters/ui.ts +16 -3
- package/src/adapters/units-entries.test.ts +190 -0
- package/src/adapters/units.ts +50 -0
- package/src/adapters/workspaces.ts +2 -1
- package/src/client/about/PluginUpdateDialog.tsx +4 -2
- package/src/client/about/ReleaseNotesDialog.tsx +4 -2
- package/src/client/chat/RollbackReviewDialog.tsx +3 -2
- package/src/client/common/Skeleton.tsx +32 -0
- package/src/client/common/api-base.test.ts +36 -0
- package/src/client/common/http.test.ts +12 -1
- package/src/client/common/http.ts +8 -2
- package/src/client/common/routes.ts +46 -3
- package/src/client/config-manager.module.css +321 -11
- package/src/client/home/HomePanel.tsx +14 -6
- package/src/client/import/ForeignImportView.tsx +3 -1
- package/src/client/import/import-wizard-steps.tsx +2 -1
- package/src/client/library/ArtifactRow.tsx +12 -2
- package/src/client/library/LibraryActions.tsx +6 -3
- package/src/client/library/LibraryPanel.tsx +21 -3
- package/src/client/library/RestorePlanView.tsx +4 -2
- package/src/client/locales.ts +3 -0
- package/src/client/market/market-api.test.ts +3 -2
- package/src/client/recovery/RecoveryPanel.tsx +54 -7
- package/src/client/recovery/RollbackCard.tsx +7 -2
- package/src/client/recovery/RollbackDiffPane.tsx +3 -2
- package/src/client/recovery/recovery-locales.ts +20 -2
- package/src/client/recovery/recovery-view.test.ts +39 -1
- package/src/client/recovery/recovery-view.ts +23 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/run-store.ts +4 -0
- package/src/client/sync/AutosyncCard.tsx +49 -6
- package/src/client/sync/MergeModal.tsx +220 -0
- package/src/client/sync/MergePreviewView.tsx +306 -0
- package/src/client/sync/SyncActivityBar.tsx +31 -0
- package/src/client/sync/SyncConfirmView.tsx +2 -1
- package/src/client/sync/SyncPanel.tsx +130 -87
- package/src/client/sync/SyncProbeRow.tsx +162 -0
- package/src/client/sync/SyncSettingsView.test.ts +27 -2
- package/src/client/sync/WorkDirCard.tsx +44 -6
- package/src/client/sync/history-model.ts +4 -1
- package/src/client/sync/merge-view.test.ts +248 -0
- package/src/client/sync/merge-view.ts +398 -0
- package/src/client/sync/probe-view.test.ts +90 -0
- package/src/client/sync/probe-view.ts +63 -0
- package/src/client/sync/remote-device-view.test.ts +35 -0
- package/src/client/sync/remote-device-view.ts +25 -0
- package/src/client/sync/sync-api.test.ts +5 -3
- package/src/client/sync/sync-api.ts +212 -2
- package/src/client/sync/sync-entry.test.ts +78 -0
- package/src/client/sync/sync-entry.ts +53 -0
- package/src/client/sync/sync-locales.ts +234 -2
- package/src/client/sync/sync-view.test.ts +18 -1
- package/src/client/sync/sync-view.ts +20 -0
- package/src/core/backup.test.ts +41 -0
- package/src/core/backup.ts +20 -0
- package/src/core/mcp-connector-store.test.ts +265 -0
- package/src/core/mcp-connector-store.ts +516 -0
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +46 -0
- package/src/core/phase3-host.ts +18 -1
- package/src/core/phase3-production-integration.test.ts +4 -0
- package/src/core/plugin-cli.test.ts +26 -0
- package/src/core/plugin-cli.ts +30 -4
- package/src/core/recovery-orchestrator.test.ts +104 -5
- package/src/core/recovery-orchestrator.ts +74 -17
- package/src/core/rollback-activity.test.ts +311 -0
- package/src/core/rollback-activity.ts +367 -0
- package/src/core/rollback-cross-session.test.ts +694 -0
- package/src/core/rollback-interrupted.test.ts +137 -0
- package/src/core/rollback-ledger.ts +32 -0
- package/src/core/rollback-merge.test.ts +130 -0
- package/src/core/rollback-merge.ts +78 -14
- package/src/core/rollback-point.ts +468 -91
- package/src/core/rollback-undo-journal.test.ts +508 -0
- package/src/core/rollback-undo-journal.ts +571 -0
- package/src/core/self-update.ts +2 -5
- package/src/core/text-fidelity.test.ts +189 -0
- package/src/core/text-fidelity.ts +197 -0
- package/src/core/types.ts +11 -3
- package/src/core/update-check.ts +2 -5
- package/src/core/validator.ts +14 -0
- package/src/foreign/bundle-origin.test.ts +265 -0
- package/src/foreign/bundle.ts +45 -5
- package/src/foreign/claude-code.test.ts +268 -0
- package/src/foreign/claude-code.ts +406 -25
- package/src/foreign/claude-plugins.test.ts +234 -0
- package/src/foreign/claude-sessions.test.ts +141 -0
- package/src/foreign/claude-sessions.ts +179 -23
- package/src/foreign/file-budget.test.ts +4 -1
- package/src/foreign/read-claude-code.test.ts +320 -0
- package/src/foreign/read-claude-code.ts +490 -71
- package/src/foreign/read-claude-plugins.ts +465 -0
- package/src/foreign/registry.ts +25 -2
- package/src/foreign/session-ir.test.ts +65 -0
- package/src/foreign/truth-table.test.ts +81 -0
- package/src/foreign/truth-table.ts +6 -3
- package/src/foreign/types.ts +162 -2
- package/src/index.facade.test.ts +26 -4
- package/src/index.ts +76 -19
- package/src/market/git-file-writer.test.ts +4 -0
- package/src/market/git-file-writer.ts +7 -1
- package/src/profiles/dsh-profile-launcher.test.ts +68 -3
- package/src/profiles/dsh-profile-launcher.ts +56 -1
- package/src/profiles/dsh-profile-shared.ts +8 -2
- package/src/routes/foreign.ts +8 -1
- package/src/routes/index.ts +1 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/sync.ts +176 -9
- package/src/schema/manifest-schema.test.ts +87 -2
- package/src/schema/manifest.ts +57 -1
- package/src/schema/types.ts +45 -2
- package/src/sync/ancestor.ts +76 -2
- package/src/sync/autosync-config.test.ts +73 -1
- package/src/sync/autosync-config.ts +124 -0
- package/src/sync/autosync-risk-tier.test.ts +38 -2
- package/src/sync/autosync-scheduler.test.ts +264 -12
- package/src/sync/autosync-scheduler.ts +296 -44
- package/src/sync/gist/gist-conditional-write.test.ts +203 -0
- package/src/sync/gist/gist-transport.ts +53 -5
- package/src/sync/gist/messages.ts +5 -0
- package/src/sync/git/git-probe.test.ts +80 -0
- package/src/sync/git/git-transport.test.ts +185 -0
- package/src/sync/git/git-transport.ts +178 -5
- package/src/sync/merge.test.ts +188 -55
- package/src/sync/merge.ts +282 -224
- package/src/sync/messages.ts +19 -0
- package/src/sync/risk.ts +29 -5
- package/src/sync/s3/messages.ts +13 -0
- package/src/sync/s3/s3-conditional-write.test.ts +330 -0
- package/src/sync/s3/s3-transport.test.ts +2 -1
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/s3/sigv4.test.ts +4 -1
- package/src/sync/s3/sigv4.ts +11 -2
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +410 -19
- package/src/sync/sync-engine.ts +602 -57
- package/src/sync/sync-history.test.ts +16 -1
- package/src/sync/sync-history.ts +26 -0
- package/src/sync/sync-state.test.ts +64 -0
- package/src/sync/sync-state.ts +79 -0
- package/src/sync/sync-watcher.test.ts +197 -0
- package/src/sync/sync-watcher.ts +246 -0
- package/src/sync/text-merge.test.ts +358 -0
- package/src/sync/text-merge.ts +554 -0
- package/src/sync/transport.ts +57 -0
- package/src/sync/watch-scope.test.ts +122 -0
- package/src/sync/watch-scope.ts +120 -0
- package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
- package/src/sync/webdav/webdav-transport.ts +137 -14
- package/src/ui/foreign-view.test.ts +34 -0
- package/src/ui/foreign-view.ts +10 -0
- package/src/ui/i18n.ts +22 -0
- package/src/ui/rollback-view.ts +14 -0
- package/src/ui/sync-settings-view.test.ts +28 -1
- package/src/ui/sync-settings-view.ts +43 -0
- package/src/ui/types.ts +22 -0
- package/src/utils/env-lock.ts +8 -5
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* 生态里的做法同样如此(dsh-profile-manager 的 `dshm start`、DSH Launcher 的桌面启动器),
|
|
10
10
|
* 都是「由启动器 spawn 一个实例」;本模块把这件事做进插件:
|
|
11
11
|
* web 形态档案 → detached 子进程 + 自动挑空闲端口 + 从启动日志里抓认证 URL + HTTP 探活。
|
|
12
|
+
* 子进程必须**直启**(node/Electron-as-node 跑 `bin.js`):Windows 上经 `.cmd` 包装脚本
|
|
13
|
+
* (`shell: true` 或中间 cmd 层)再 `detached: true` 会丢掉孙进程的 stdout/stderr,
|
|
14
|
+
* 日志文件恒 0 字节 ⇒ 永远抓不到 `dsh web: <带 token 的 URL>`(真机实测,见 findCliEntryForShim)。
|
|
12
15
|
*
|
|
13
16
|
* 设计约束:
|
|
14
17
|
* - 只消费 DSH 的稳定表面(启动参数 + 启动日志里的 `dsh web:` 行),不 import 任何
|
|
@@ -173,7 +176,18 @@ export function defaultResolveCli(): DshCliCommand | null {
|
|
|
173
176
|
}
|
|
174
177
|
return null
|
|
175
178
|
}
|
|
176
|
-
|
|
179
|
+
const needsShell = /\.(cmd|bat)$/i.test(fromPath)
|
|
180
|
+
// Windows 的 .cmd/.bat 只能经 shell 起,而「中间 cmd 层 + detached」会吞掉孙进程的
|
|
181
|
+
// stdio(日志恒 0 字节 ⇒ 永远抓不到带 token 的 URL)。能反解出真入口就直接跑,
|
|
182
|
+
// 只有反解不出来时才退回包装脚本(那时日志里没有 URL,UI 会如实报 urlNotFound)。
|
|
183
|
+
if (needsShell) {
|
|
184
|
+
const entry = findCliEntryForShim(fromPath)
|
|
185
|
+
if (entry !== null) {
|
|
186
|
+
const runner = resolveDirectCliRunner()
|
|
187
|
+
return { command: runner.command, prefixArgs: [entry], shell: false, env: runner.env }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { command: fromPath, prefixArgs: [], shell: needsShell }
|
|
177
191
|
}
|
|
178
192
|
|
|
179
193
|
/** bin.js 是否属于 @deepseek-ai/dsh(读上一级的 package.json.name)。 */
|
|
@@ -199,6 +213,47 @@ function findOnPath(names: readonly string[]): string | null {
|
|
|
199
213
|
return null
|
|
200
214
|
}
|
|
201
215
|
|
|
216
|
+
/**
|
|
217
|
+
* 直启 bin.js 用的运行时:宿主是 Electron 时必须以 `ELECTRON_RUN_AS_NODE=1` 当 node 用
|
|
218
|
+
* (桌面端宿主 `process.execPath` 是 DeepSeek Harness.exe,真机实测可正常跑 CLI)。
|
|
219
|
+
*/
|
|
220
|
+
export function resolveDirectCliRunner(versions: Readonly<Record<string, string | undefined>> = process.versions): { command: string; env?: Readonly<Record<string, string>> } {
|
|
221
|
+
if (versions['electron'] !== undefined) return { command: process.execPath, env: { ELECTRON_RUN_AS_NODE: '1' } }
|
|
222
|
+
return { command: process.execPath }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 从 PATH 上的 dsh 包装脚本反解真正的 CLI 入口(`.../@deepseek-ai/dsh/lib/bin.js`)。
|
|
227
|
+
*
|
|
228
|
+
* 为什么必须反解(真机实测根因):Windows 上 `.cmd/.bat` 只能经 shell 起,而
|
|
229
|
+
* **中间 cmd 层 + `detached: true` 会丢掉孙进程的 stdout/stderr** ——
|
|
230
|
+
* `dsh.cmd --version` 经 detached 起时日志恒 0 字节(非 detached 正常),
|
|
231
|
+
* 于是启动器永远读不到 `dsh web: <带 token 的 URL>` 那行,用户只看到
|
|
232
|
+
* 「20 秒内未确认就绪 / 没能抓到带 token 的 URL」。直启 bin.js 则正常(约 8 秒出 URL)。
|
|
233
|
+
*
|
|
234
|
+
* 先按 npm/pnpm 的常见布局找同目录 `node_modules`,再退回脚本内嵌的入口路径。
|
|
235
|
+
*/
|
|
236
|
+
export function findCliEntryForShim(shimPath: string): string | null {
|
|
237
|
+
const dir = dirname(shimPath)
|
|
238
|
+
const candidates = [
|
|
239
|
+
join(dir, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'), // npm 全局前缀(dsh.cmd 同目录)
|
|
240
|
+
join(dir, '..', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'), // <prefix>/bin/dsh.cmd
|
|
241
|
+
join(dir, '..', '..', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'),
|
|
242
|
+
join(dir, '..', '@deepseek-ai', 'dsh', 'lib', 'bin.js'), // pnpm <store>/.bin/dsh.cmd
|
|
243
|
+
]
|
|
244
|
+
for (const candidate of candidates) {
|
|
245
|
+
if (isDshBinPath(candidate)) return candidate
|
|
246
|
+
}
|
|
247
|
+
const text = readTextSafe(shimPath)
|
|
248
|
+
if (text === null) return null
|
|
249
|
+
// 脚本内嵌路径可能是相对当前目录的(`%dp0%\node_modules\…`)也可能是 `..\node_modules\…`,
|
|
250
|
+
// 故把可选的前导相对段一起抓下来,再按 shim 所在目录解析。
|
|
251
|
+
const embedded = /(?:\.\.[\\/]|[.][\\/])?node_modules[\\/]+@deepseek-ai[\\/]+dsh[\\/]+lib[\\/]+bin\.js/iu.exec(text)
|
|
252
|
+
if (embedded === null) return null
|
|
253
|
+
const resolved = join(dir, embedded[0])
|
|
254
|
+
return isDshBinPath(resolved) ? resolved : null
|
|
255
|
+
}
|
|
256
|
+
|
|
202
257
|
/** 档案 → 是否可启动(唯一判据在 shared,UI 与本模块共用)。 */
|
|
203
258
|
export { isLaunchableShape }
|
|
204
259
|
|
|
@@ -144,8 +144,14 @@ export type DshProfileStopOutcome = 'graceful' | 'killed' | 'already-stopped'
|
|
|
144
144
|
/** 启动回执里的非致命告警码。 */
|
|
145
145
|
export type DshProfileLaunchWarning = 'notReady' | 'urlNotFound'
|
|
146
146
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
147
|
+
/**
|
|
148
|
+
* 等待新实例就绪的上限(宿主探活默认值;UI 文案引用同一常量,避免两处各写一个数字)。
|
|
149
|
+
*
|
|
150
|
+
* 真机口径(web 档案):端口约 3 秒就 LISTENING,但带 token 的 URL 要等 dsh 把整棵插件树
|
|
151
|
+
* settle 完 + startup audit 走完才打印,实测 8–10 秒,插件多的档案会更久(有过 >20 秒的样本)。
|
|
152
|
+
* 20 秒会把「慢但成功」误判成未就绪,故放宽到 30 秒。
|
|
153
|
+
*/
|
|
154
|
+
export const DSH_PROFILE_LAUNCH_TIMEOUT_MS = 30_000
|
|
149
155
|
|
|
150
156
|
/** GET /profiles 的响应负载(列表 + 当前运行 + 运行中实例 + 模板清单)。 */
|
|
151
157
|
export interface DshProfilesSnapshot {
|
package/src/routes/foreign.ts
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* ② **不做导入**:本路由只产包,**绝不**触碰 $DSH_HOME 下的配置。导入仍由既有
|
|
18
18
|
* /analyze → /plan → /execute 完成(导入前快照、冲突判定、dry-run 全部自动生效);
|
|
19
19
|
* ③ **凭据值绝不进响应**:只回 skipped 机器码 + 凭据**引用名**(值在转换层就已被剥离,
|
|
20
|
-
* 见 src/foreign/types.ts
|
|
20
|
+
* 见 src/foreign/types.ts 的不变量①);溯源块(origin:来源标识 / 位置 / 项目根 / 插件元数据 /
|
|
21
|
+
* 跳过聚合 / 凭据引用名)同样**只含标识与计数**,绝无任何配置值;
|
|
21
22
|
* ④ **空结果不静默成功**:一个分区都产不出来 → 400 + 稳定码(与 CLI 的退出码 1 同口径),
|
|
22
23
|
* 否则用户会拿到一个自检通过却什么都没有的包。
|
|
23
24
|
*
|
|
@@ -217,6 +218,12 @@ export function foreignRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
217
218
|
skipped: result.skipped,
|
|
218
219
|
// 凭据只给**引用名**(形如 mcp:gitnexus:GITHUB_TOKEN):导入后由用户在 DSH 里补录
|
|
219
220
|
credentialRefs: result.credentialRefs,
|
|
221
|
+
/**
|
|
222
|
+
* 溯源块(**additive**):来源标识 / 读取位置 / 项目根 / 插件元数据 / 跳过聚合 /
|
|
223
|
+
* 凭据引用名 —— 界面据此让用户核对「这包从哪来」(同一位也写进 manifest.foreign)。
|
|
224
|
+
* 翻译层没给 origin 时该键**不出现**(JSON.stringify 丢弃 undefined)⇒ 旧响应逐字不变。
|
|
225
|
+
*/
|
|
226
|
+
origin: result.origin,
|
|
220
227
|
/** 检测到的本机位置(相对 home),供界面显示「从哪儿读的」 */
|
|
221
228
|
detectedPaths: detection.paths,
|
|
222
229
|
conflictPolicy: FOREIGN_CONFLICT_POLICY,
|
package/src/routes/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 顺序对匹配无影响:DSH webServer 的契约是「命名路由必须互不相同,注册顺序不影响请求」
|
|
5
5
|
* (见 @deepseek-ai/dsh-host-webserver 的 WebServer 文档)。src/index.ts 保留的 7 条被源码级
|
|
6
|
-
* 守卫按文件窗口钉住,其余
|
|
6
|
+
* 守卫按文件窗口钉住,其余 75 条在这里组装。
|
|
7
7
|
*/
|
|
8
8
|
import type { WebRoute } from './kit.ts'
|
|
9
9
|
import type { RoutesEnv } from './context.ts'
|
package/src/routes/kit.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* - 「每条路由首行 guard」这条安全不变量此前**只写在注释里**,零测试覆盖;
|
|
9
9
|
* - 3 条 prefix 路由自造围栏(裸 isLoopbackRequest + 自判方法)。
|
|
10
10
|
*
|
|
11
|
-
* 计数口径(三路一致 =
|
|
12
|
-
* 路由快照
|
|
11
|
+
* 计数口径(三路一致 = 82,勿再照抄旧审计初稿的计数 —— 那是计数口径 overcount):
|
|
12
|
+
* 路由快照 82 条 = 现行 `endpoint()` 声明 82 条 = 运行期 `buildRoutes()` 75 条 + `src/index.ts` 保留的 7 条。
|
|
13
13
|
* 增删路由后必须同步这段计数、`src/routes/index.ts` 的头注释与 parity 快照清单;`tests/route/route-parity.test.ts`
|
|
14
14
|
* 的「路由计数散文」守卫会拿真实声明数逐条对账(写错即红灯)。
|
|
15
15
|
*
|
package/src/routes/sync.ts
CHANGED
|
@@ -45,12 +45,13 @@ import { REVIEW_KINDS, ROUTE_TIMEOUT_MS, SYNC_CREDENTIAL_REF, SYNC_WEBDAV_CREDEN
|
|
|
45
45
|
import { GitHubApiError } from '../market/github-repos.ts'
|
|
46
46
|
import type { SectionId } from '../schema/types.ts'
|
|
47
47
|
import { redact } from '../security/redaction.ts'
|
|
48
|
-
import { readAutosyncConfig, writeAutosyncConfig } from '../sync/autosync-config.ts'
|
|
48
|
+
import { shouldRunOnEnable, readAutosyncConfig, writeAutosyncConfig, clampRemoteCheckMs, clearLastRunResult } from '../sync/autosync-config.ts'
|
|
49
49
|
import { isValidExportFileName } from '../sync/backup-files.ts'
|
|
50
50
|
import { DeviceFlowStore } from '../sync/github-auth.ts'
|
|
51
51
|
import { channelOf, clearSyncChannel, isWebDavConfig, listCloudConfiguredProviders, parseSyncChannel, readFullSyncConfig, writeSyncConfig, gistChannelView, s3ChannelView, cloudSecretRef, S3_COMPAT_PROVIDERS, GIST_PROVIDER } from '../sync/sync-config.ts'
|
|
52
52
|
import type { CloudS3ChannelView, CloudGistChannelView, CloudSecretWriter, FullSyncConfig, SyncConfig, SyncTransportType } from '../sync/sync-config.ts'
|
|
53
53
|
import { SyncEngine } from '../sync/sync-engine.ts'
|
|
54
|
+
import { isProbeUnsupported } from '../sync/transport.ts'
|
|
54
55
|
import type { ApplyItemsReport } from '../sync/sync-engine.ts'
|
|
55
56
|
import { readSyncHistory } from '../sync/sync-history.ts'
|
|
56
57
|
import { SYNC_SELECTION_SCHEMA_VERSION, normalizeSessionsInclude, normalizeSessionsLimit, writeSyncSelection } from '../sync/sync-selection.ts'
|
|
@@ -137,6 +138,50 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
137
138
|
set: async (ref, value) => { await credentials.set(credentialRef(ref), value) },
|
|
138
139
|
has: async (ref) => (await credentials.describe(credentialRef(ref))).configured,
|
|
139
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* T-ui-merge:合并预览 / 合并执行共用的引擎选项。
|
|
143
|
+
*
|
|
144
|
+
* 解密密码口径与 /sync/sync 完全一致:请求体优先,其次已保存的 DECRYPT 凭据
|
|
145
|
+
* (syncPasswordRef('DECRYPT', channel))。密码只存在于内存,绝不写进响应 / 日志 / 同步文件;
|
|
146
|
+
* 明文快照根本不会被解密(引擎只在 manifest.encrypted 时才用密码)。
|
|
147
|
+
*/
|
|
148
|
+
const mergeEngineOptions = async (
|
|
149
|
+
body: Record<string, unknown>,
|
|
150
|
+
syncCfg: SyncConfig,
|
|
151
|
+
): Promise<{ snapshotId?: string; password?: string; pathMappings?: PathMapping[] }> => {
|
|
152
|
+
const snapshotId = typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined
|
|
153
|
+
const savedDecryptPassword = await resolveSyncPassword(syncPasswordRef('DECRYPT', channelOf(syncCfg)))
|
|
154
|
+
const decryptPassword = typeof body['decryptPassword'] === 'string' && body['decryptPassword'] !== ''
|
|
155
|
+
? body['decryptPassword']
|
|
156
|
+
: savedDecryptPassword
|
|
157
|
+
const pathMappings = extractPathMappings(body)
|
|
158
|
+
return {
|
|
159
|
+
...(snapshotId === undefined ? {} : { snapshotId }),
|
|
160
|
+
...(decryptPassword === undefined ? {} : { password: decryptPassword }),
|
|
161
|
+
...(pathMappings.length === 0 ? {} : { pathMappings }),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* 合并裁决清单解析(T-ui-merge)。**粒度 = 分区 id**(冻结口径,不做 'id::path' 复合 id)。
|
|
166
|
+
*
|
|
167
|
+
* 非法条目直接丢弃 —— 引擎按「未裁决」处理,该分区进 pendingConflicts,界面会如实显示;
|
|
168
|
+
* 这样既不需要新增宿主文案键,也不会因为客户端字段拼错而静默写坏别的分区。
|
|
169
|
+
*/
|
|
170
|
+
const extractMergeResolutions = (body: Record<string, unknown>): Array<{ id: SectionId; action: 'keepLocal' | 'useRemote' | 'useMerged' | 'skip' }> => {
|
|
171
|
+
const raw = body['resolutions']
|
|
172
|
+
if (!Array.isArray(raw)) return []
|
|
173
|
+
const out: Array<{ id: SectionId; action: 'keepLocal' | 'useRemote' | 'useMerged' | 'skip' }> = []
|
|
174
|
+
for (const entry of raw) {
|
|
175
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
|
|
176
|
+
const rec = entry as Record<string, unknown>
|
|
177
|
+
const id = rec['id']
|
|
178
|
+
const action = rec['action']
|
|
179
|
+
if (typeof id !== 'string' || id === '') continue
|
|
180
|
+
if (action !== 'keepLocal' && action !== 'useRemote' && action !== 'useMerged' && action !== 'skip') continue
|
|
181
|
+
out.push({ id: id as SectionId, action })
|
|
182
|
+
}
|
|
183
|
+
return out
|
|
184
|
+
}
|
|
140
185
|
return [
|
|
141
186
|
// ------------------------------------------------------ sync/status
|
|
142
187
|
// m-sync-ui:同步状态(通道配置 / 凭据状态 / 上次同步 / 分区数)。只读,无 secret 值。
|
|
@@ -165,7 +210,8 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
165
210
|
: undefined
|
|
166
211
|
// 云端点配置视图(只回 secretStored 布尔;与当前通道无关,配置过即回填)
|
|
167
212
|
const cloud = await cloudChannelViews(full, credentials)
|
|
168
|
-
// issue #79:git 工作副本是否已跟随 repoUrl
|
|
213
|
+
// issue #79 / #104 建议 3:git 工作副本是否已跟随 repoUrl,外加「领先远端提交数 /
|
|
214
|
+
// 远端快照数」(只读自检;后者是「本地一直没真正备份」的预警依据)。
|
|
169
215
|
// 读不出来时**如实回传错误**(workDirError),不让状态接口静默少一块信息。
|
|
170
216
|
const gitRepoUrl = full?.git?.repoUrl ?? ''
|
|
171
217
|
let workDir: Awaited<ReturnType<typeof syncWorkDir.inspect>> | null = null
|
|
@@ -185,6 +231,9 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
185
231
|
// 已保存的云端点 provider 清单(切换 provider 时回填用;只有名字,无任何值)
|
|
186
232
|
cloudSavedProviders: await listCloudConfiguredProviders(syncDir),
|
|
187
233
|
lastSyncAt: state.lastSyncAt === '' ? undefined : state.lastSyncAt,
|
|
234
|
+
// 方案 A(同步页按钮瘦身):是否存在同步基线(本地祖先指针)。**只回布尔,绝不回快照 id** ——
|
|
235
|
+
// 界面据此在「有基线 → 三方裁决视图 / 无基线 → 导入确认」之间分派;缺省 undefined 按无基线处理。
|
|
236
|
+
hasBaseline: state.lastSnapshotId !== '',
|
|
188
237
|
sectionCount: Object.keys(state.sections).length,
|
|
189
238
|
lastTransport: state.transport,
|
|
190
239
|
// 上次选择的同步通道(磁盘 ui-prefs;UI 回填优先于此,localStorage 仅兜底)
|
|
@@ -209,9 +258,11 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
209
258
|
}
|
|
210
259
|
}),
|
|
211
260
|
// ------------------------------------------------------ sync/workdir/reset
|
|
212
|
-
// issue #79:工作副本的自救入口(改造前 App 内完全没有 —— 改了仓库地址只能手工
|
|
213
|
-
// git remote set-url,或删掉 <dataDir>/sync/work
|
|
261
|
+
// issue #79 / #104:工作副本的自救入口(改造前 App 内完全没有 —— 改了仓库地址只能手工
|
|
262
|
+
// git remote set-url,或删掉 <dataDir>/sync/work 重来)。三种模式,破坏性清清楚楚:
|
|
214
263
|
// relink = 只把 origin / push URL 重指向当前配置地址(非破坏性:不碰工作树与本地提交);
|
|
264
|
+
// adopt = 把远端分支合并进现有工作副本(issue #104:换成另一个仓库、或远端历史被重写后
|
|
265
|
+
// 两条历史不相交,pull --ff-only 永远失败);不改写任何本地提交,可 revert;
|
|
215
266
|
// reclone = 删除工作副本目录后重新 clone(**丢弃未上行的本地提交**)→ 必须显式 confirm。
|
|
216
267
|
// 写动作 → 过 mutation gate(SAFE MODE / 环境锁);删除边界见 GitTransport.rebuildWorkDir
|
|
217
268
|
// (目录非空且无 .git 一律拒绝,绝不删来路不明的目录)。
|
|
@@ -219,8 +270,10 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
219
270
|
try {
|
|
220
271
|
const body = await requireJsonObject(req)
|
|
221
272
|
const mode = body['mode'] === undefined ? 'relink' : body['mode']
|
|
222
|
-
if (mode !== 'relink' && mode !== 'reclone') {
|
|
223
|
-
|
|
273
|
+
if (mode !== 'relink' && mode !== 'adopt' && mode !== 'reclone') {
|
|
274
|
+
// 用追加键(含三种模式)而不是既有的 sync.workdirModeInvalid:后者有冻结指纹,
|
|
275
|
+
// 只列出了两种模式,继续用会把「adopt 也合法」说反。
|
|
276
|
+
writeJson(res, 400, { error: msg('sync.workdirModeInvalidFull', { mode: JSON.stringify(body['mode']) }) })
|
|
224
277
|
return
|
|
225
278
|
}
|
|
226
279
|
if (mode === 'reclone' && body['confirm'] !== true) {
|
|
@@ -657,6 +710,39 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
657
710
|
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
658
711
|
}
|
|
659
712
|
}),
|
|
713
|
+
// ------------------------------------------------------ sync/probe
|
|
714
|
+
// cloud-sync 借鉴项 ②:**只读**连通性自检(凭据 / 端点 / 目标容器 / 目录可达),
|
|
715
|
+
// 供 UI「测试连接」按钮拿到可直接展示的结论(例如 S3 兼容端点该把 region 填成哪个值)。
|
|
716
|
+
// 只探**已保存**的通道配置(走与 push/pull 同一条配置路径,绝不拿表单里未保存的值去发请求)。
|
|
717
|
+
// 远端唯一写副作用 = webdav 幂等 MKCOL 补齐上级目录(那正是它要修的故障:目录不存在)。
|
|
718
|
+
// 失败一律 200 + ok:false —— 「测试连接」是**预期会失败**的诊断动作,用 5xx 表达会被 UI
|
|
719
|
+
// 当成基础设施故障;文案由通道层本地化并脱敏后原样带回。
|
|
720
|
+
endpoint({ path: '/api/dsh-config-manager/sync/probe', methods: ['POST'] }, async (req, res) => {
|
|
721
|
+
const body = await requireJsonObject(req)
|
|
722
|
+
try {
|
|
723
|
+
const syncCfg = await prepareSync(body)
|
|
724
|
+
const engine = makeSyncEngine(syncCfg, { optInSections: selectionOptInSections(channelOf(syncCfg)) })
|
|
725
|
+
const result = await engine.probe()
|
|
726
|
+
writeJson(res, 200, {
|
|
727
|
+
ok: true,
|
|
728
|
+
transport: channelOf(syncCfg),
|
|
729
|
+
message: result.message,
|
|
730
|
+
...(result.hint !== undefined ? { hint: result.hint } : {}),
|
|
731
|
+
...(result.snapshotCount !== undefined ? { snapshotCount: result.snapshotCount } : {}),
|
|
732
|
+
...(result.createdCollections !== undefined && result.createdCollections.length > 0
|
|
733
|
+
? { createdCollections: result.createdCollections }
|
|
734
|
+
: {}),
|
|
735
|
+
})
|
|
736
|
+
} catch (error) {
|
|
737
|
+
// 「该通道不提供自检」是**能力边界**(git 通道按 fast-forward 语义自证),不是故障:
|
|
738
|
+
// 用稳定错误码标记,界面据此按中性提示渲染,绝不显示成「操作失败」。
|
|
739
|
+
writeJson(res, 200, {
|
|
740
|
+
ok: false,
|
|
741
|
+
error: error instanceof Error ? error.message : String(error),
|
|
742
|
+
...(isProbeUnsupported(error) ? { unsupported: true } : {}),
|
|
743
|
+
})
|
|
744
|
+
}
|
|
745
|
+
}),
|
|
660
746
|
// ------------------------------------------------------ sync/download
|
|
661
747
|
// 「拉取即导入」:把远端快照**落地成本机 ZIP** 并返回路径,供导入向导消费。
|
|
662
748
|
// 与 /sync/pull 的分工:pull 只做只读差异预览(临时 ZIP 用完即删);
|
|
@@ -741,6 +827,15 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
741
827
|
sectionCount: m.manifest.sectionIds.length,
|
|
742
828
|
platform: m.manifest.platform,
|
|
743
829
|
dshVersion: m.manifest.dshVersion,
|
|
830
|
+
// cloud-sync 借鉴项 ⑥:**推送方设备身份**(写在快照 manifest 里;旧快照没有 → 字段不出现)。
|
|
831
|
+
// 只回名字与稳定 id(都是本机自己写上去的展示信息,非秘密);缺字段时绝不回空串占位,
|
|
832
|
+
// 让界面能如实区分「旧快照没有这个字段」与「设备名是空的」。
|
|
833
|
+
...(typeof m.manifest.deviceName === 'string' && m.manifest.deviceName !== ''
|
|
834
|
+
? { deviceName: m.manifest.deviceName }
|
|
835
|
+
: {}),
|
|
836
|
+
...(typeof m.manifest.deviceId === 'string' && m.manifest.deviceId !== ''
|
|
837
|
+
? { deviceId: m.manifest.deviceId }
|
|
838
|
+
: {}),
|
|
744
839
|
}))
|
|
745
840
|
const state = await loadSyncState(syncDir)
|
|
746
841
|
// issue #59:把「远端有但读不出来」的快照如实回传(绝不静默跳过)——
|
|
@@ -965,7 +1060,8 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
965
1060
|
}
|
|
966
1061
|
}),
|
|
967
1062
|
// ------------------------------------------------------ sync/autosync
|
|
968
|
-
// m-sync-v2
|
|
1063
|
+
// m-sync-v2:自动同步配置读写(按通道:开关 + 兜底间隔 + 启动阈值 + realtime 监听/远端轮询
|
|
1064
|
+
// + 自动应用等级 + 冲突策略 + 运行状态)。
|
|
969
1065
|
// GET = 读全部通道状态({ git, webdav });POST = 写指定通道(body.transport,缺省 git)。
|
|
970
1066
|
// 同一路径注册为一个 exact 路由(方法内部分发),避免 webserver 对重复 exact 路径报错。
|
|
971
1067
|
endpoint({ path: '/api/dsh-config-manager/sync/autosync', methods: ['GET', 'POST'] }, async (req, res) => {
|
|
@@ -982,13 +1078,41 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
982
1078
|
// 按通道读写:git/webdav 各自的自动同步配置与运行状态独立(缺省 git 兜底)
|
|
983
1079
|
const channel: SyncTransportType = parseSyncChannel(body['transport']) ?? 'git'
|
|
984
1080
|
const cfg = await readAutosyncConfig(syncDir, channel)
|
|
1081
|
+
// 「这次 POST 是不是把开关从关拨到开」必须在覆盖字段之前拍下来(issue #105 建议 4)
|
|
1082
|
+
const wasEnabled = cfg.enabled
|
|
985
1083
|
if (typeof body['enabled'] === 'boolean') cfg.enabled = body['enabled']
|
|
986
1084
|
if (typeof body['interval'] === 'string' && isAutosyncInterval(body['interval'])) cfg.interval = body['interval']
|
|
987
1085
|
if (typeof body['startupMinIntervalMs'] === 'number' && Number.isFinite(body['startupMinIntervalMs']) && body['startupMinIntervalMs'] > 0) {
|
|
988
1086
|
cfg.startupMinIntervalMs = body['startupMinIntervalMs']
|
|
989
1087
|
}
|
|
990
|
-
|
|
991
|
-
if (
|
|
1088
|
+
// 真·自动同步:本地改动即时触发开关 + 远端轮询节奏(非法值一律忽略,绝不写 NaN)
|
|
1089
|
+
if (typeof body['realtime'] === 'boolean') cfg.realtime = body['realtime']
|
|
1090
|
+
if (typeof body['remoteCheckMs'] === 'number' && Number.isFinite(body['remoteCheckMs'])) {
|
|
1091
|
+
cfg.remoteCheckMs = clampRemoteCheckMs(body['remoteCheckMs'])
|
|
1092
|
+
}
|
|
1093
|
+
// 自动应用等级:low = 只自动落低风险分区;medium = plugins/mcp 也自动落盘
|
|
1094
|
+
if (body['autoApplyTier'] === 'low' || body['autoApplyTier'] === 'medium') {
|
|
1095
|
+
cfg.autoApplyTier = body['autoApplyTier']
|
|
1096
|
+
}
|
|
1097
|
+
// 冲突策略:ask = 保留冲突待人工裁决;local = 保留本机并推上去(缺省)
|
|
1098
|
+
if (body['conflictPolicy'] === 'ask' || body['conflictPolicy'] === 'local') {
|
|
1099
|
+
cfg.conflictPolicy = body['conflictPolicy']
|
|
1100
|
+
}
|
|
1101
|
+
// issue #105:这里写的是**配置**,不是一轮运行结果。此前 readAutosyncConfig 读到的
|
|
1102
|
+
// 上一轮 lastRunMessage/lastRunSkipReason 会被原样写回("坐实"),于是十天前那次失败的
|
|
1103
|
+
// 错误原文会被一次纯改间隔的保存续命,卡片上继续挂着早已不成立的故障。
|
|
1104
|
+
// 现在:配置落盘前清空上一轮结论 —— 配置变更 ≠ 本轮结论。
|
|
1105
|
+
// issue #105 建议 4:勾选开关是「用户刚刚要求它开始工作」,不该只重排定时器、让卡片
|
|
1106
|
+
// 继续显示上一轮(甚至是十天前)的结论。若距上次运行已超过启动阈值,立刻跑一轮。
|
|
1107
|
+
const runNow = shouldRunOnEnable({ enabled: wasEnabled }, cfg)
|
|
1108
|
+
await writeAutosyncConfig(syncDir, channel, clearLastRunResult(cfg))
|
|
1109
|
+
if (scheduler) {
|
|
1110
|
+
scheduler.reload().catch(() => { /* 尽力而为 */ })
|
|
1111
|
+
// 不 await:POST 是配置保存,不能让页面等一次网络同步;结果由调度器自己写进
|
|
1112
|
+
// sync-autosync.json / sync-history.json。并发由 runOnce 的 running 与 runs 全局防重
|
|
1113
|
+
// 挡住(在跑时返回 skipped 而不是排队),所以这里不需要额外门控。
|
|
1114
|
+
if (runNow) void scheduler.runOnce(channel, { reason: 'enable-toggle' }).catch(() => { /* 结果已落状态 */ })
|
|
1115
|
+
}
|
|
992
1116
|
writeJson(res, 200, await buildAutosyncStatus(syncDir, channel))
|
|
993
1117
|
} catch (error) {
|
|
994
1118
|
writeSyncRouteError(res, error)
|
|
@@ -1091,5 +1215,48 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
1091
1215
|
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
1092
1216
|
}
|
|
1093
1217
|
})),
|
|
1218
|
+
// ------------------------------------------------------ sync/merge-preview
|
|
1219
|
+
// T-ui-merge:远程合并**只读**预览(三方合并 + 冲突清单 + 摘要)。
|
|
1220
|
+
// 零写入 ⇒ 不过 mutation gate、也不登记同步会话(临时 ZIP 由引擎内部用完即清);
|
|
1221
|
+
// 解密密码只走内存,响应体不含任何凭据值。
|
|
1222
|
+
endpoint({ path: '/api/dsh-config-manager/sync/merge-preview', methods: ['POST'] }, async (req, res) => {
|
|
1223
|
+
const body = await requireJsonObject(req)
|
|
1224
|
+
try {
|
|
1225
|
+
const syncCfg = await prepareSync(body)
|
|
1226
|
+
const engine = makeSyncEngine(syncCfg, { optInSections: selectionOptInSections(channelOf(syncCfg)) })
|
|
1227
|
+
const opts = await mergeEngineOptions(body, syncCfg)
|
|
1228
|
+
const preview = await withTimeout(
|
|
1229
|
+
engine.previewMerge(opts),
|
|
1230
|
+
ROUTE_TIMEOUT_MS,
|
|
1231
|
+
msg('host.syncPullTimeout'),
|
|
1232
|
+
)
|
|
1233
|
+
writeJson(res, 200, preview)
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
writeSyncRouteError(res, error)
|
|
1236
|
+
}
|
|
1237
|
+
}),
|
|
1238
|
+
// ------------------------------------------------------ sync/apply-merge
|
|
1239
|
+
// T-ui-merge:按**分区级**裁决执行合并(写动作 ⇒ mutation gate 'sync-merge-apply')。
|
|
1240
|
+
// 响应直接透传引擎报告(字段与 /sync/apply-items 同风格):其中 pendingConflicts =
|
|
1241
|
+
// 执行后仍待裁决的分区 —— skip 不推进同步基线,界面必须如实说明「下次同步仍会出现」。
|
|
1242
|
+
endpoint({ path: '/api/dsh-config-manager/sync/apply-merge', methods: ['POST'] }, withMutationGate('sync-merge-apply', async (req, res, _lockCtx, journalCtx) => {
|
|
1243
|
+
const body = await requireJsonObject(req)
|
|
1244
|
+
try {
|
|
1245
|
+
const syncCfg = await prepareSync(body)
|
|
1246
|
+
const engine = makeSyncEngine(syncCfg, { optInSections: selectionOptInSections(channelOf(syncCfg)) })
|
|
1247
|
+
const resolutions = extractMergeResolutions(body)
|
|
1248
|
+
const opts = await mergeEngineOptions(body, syncCfg)
|
|
1249
|
+
// snapshotBinding:与 /sync/apply-items 同口径把 journal 绑定面交给引擎(审计 P0-23),
|
|
1250
|
+
// restoreId 与事务日志因此同源,异常路径下回滚入口拿到的也是同一份快照。
|
|
1251
|
+
const report = await withTimeout(
|
|
1252
|
+
engine.applyMergeResolutions(resolutions, { ...opts, ...(journalCtx !== undefined ? { snapshotBinding: journalCtx } : {}) }),
|
|
1253
|
+
ROUTE_TIMEOUT_MS,
|
|
1254
|
+
msg('host.syncPullTimeout'),
|
|
1255
|
+
)
|
|
1256
|
+
writeJson(res, 200, report)
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
writeSyncRouteError(res, error)
|
|
1259
|
+
}
|
|
1260
|
+
})),
|
|
1094
1261
|
]
|
|
1095
1262
|
}
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* 发布出去的那份文件;schema 一旦用上未支持的关键字,K-03 会立刻红灯。
|
|
14
14
|
*
|
|
15
15
|
* 覆盖:K-* schema 自身健全性 / S-* 真实 buildManifest 产物契约 / R-* 拒绝路径与
|
|
16
|
-
* validateManifest 逐条一致 / C-* 已知兼容语义(未知分区只 warning
|
|
16
|
+
* validateManifest 逐条一致 / C-* 已知兼容语义(未知分区只 warning)与已记录的语义落差 /
|
|
17
|
+
* F-* manifest.foreign(外部来源溯源块)的宽容契约(旧包缺省合法、形状不对只 warning)。
|
|
17
18
|
*/
|
|
18
19
|
import test from 'node:test';
|
|
19
20
|
import assert from 'node:assert/strict';
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
} from './manifest.ts';
|
|
26
27
|
import { SECTION_IDS } from './config.ts';
|
|
27
28
|
import { CURRENT_SCHEMA_VERSION } from './versions.ts';
|
|
28
|
-
import type { Manifest } from './types.ts';
|
|
29
|
+
import type { Manifest, ManifestForeignOrigin } from './types.ts';
|
|
29
30
|
|
|
30
31
|
/* ============================================================================
|
|
31
32
|
* (a) 最小 JSON Schema 校验器 —— 只覆盖本 schema 使用的关键字集合
|
|
@@ -517,3 +518,87 @@ test('C-07 schemaVersion 的「是否受支持」不属结构校验:schemaVers
|
|
|
517
518
|
assert.deepEqual(errorPaths(m), [], '实现同样只在版本协商阶段拒绝(规格 §6.2)');
|
|
518
519
|
assert.doesNotThrow(() => parseManifest(JSON.stringify(m)), 'parseManifest 不做版本协商');
|
|
519
520
|
});
|
|
521
|
+
|
|
522
|
+
/* ============================================================================
|
|
523
|
+
* F-*:manifest.foreign(外部来源溯源块,2026-10-18 新增)
|
|
524
|
+
*
|
|
525
|
+
* 契约:**只允许加严为 warning,绝不拒绝**。
|
|
526
|
+
* - 旧包没有该字段 ⇒ 合法(向后兼容是本次改动的第一约束);
|
|
527
|
+
* - 形状/内部字段类型不对 ⇒ 仅 warning,且 parseManifest 不得抛错(否则畸形元数据会
|
|
528
|
+
* 把一个**内容完全可导入**的包整体挡在门外 —— 元数据永远不该有这种权力)。
|
|
529
|
+
* ==========================================================================*/
|
|
530
|
+
|
|
531
|
+
/** 一个字段齐备的合法溯源块(夹具里只有标识与计数,无任何配置值) */
|
|
532
|
+
function validForeign(): Record<string, unknown> {
|
|
533
|
+
return {
|
|
534
|
+
agent: 'claude-code',
|
|
535
|
+
locations: ['.claude.json', '.claude/CLAUDE.md'],
|
|
536
|
+
sourceVersion: '1.2.3',
|
|
537
|
+
projects: ['D:/proj/app'],
|
|
538
|
+
plugins: [
|
|
539
|
+
{ name: 'demo-plugin', version: '1.0.0', marketplace: 'demo-mkt', repo: 'owner/repo', ref: 'main', components: ['skills'] },
|
|
540
|
+
],
|
|
541
|
+
skipped: [{ code: 'unsupported-hooks', count: 2 }],
|
|
542
|
+
credentialRefs: ['mcp:svc:GITHUB_TOKEN'],
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
test('F-01 旧包(无 foreign 键)仍合法:buildManifest 缺省不写该键、零 issue', () => {
|
|
547
|
+
const m = baselineManifest();
|
|
548
|
+
assert.equal(
|
|
549
|
+
Object.prototype.hasOwnProperty.call(m, 'foreign'), false,
|
|
550
|
+
'未传 foreign 时该键不得出现(DSH 自产备份逐字不变)',
|
|
551
|
+
);
|
|
552
|
+
assert.deepEqual(validateManifest(m), [], '无 foreign 的旧 manifest 必须零 issue');
|
|
553
|
+
assert.deepEqual(validateAgainstSchema(SCHEMA, m), [], 'schema 也不得因此拒绝');
|
|
554
|
+
assert.doesNotThrow(() => parseManifest(serializeManifest(m)), '旧包解析照旧');
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('F-02 传了 foreign 才写入:原样进 manifest,serialize → parse 往返保留', () => {
|
|
558
|
+
const foreign = validForeign() as unknown as ManifestForeignOrigin;
|
|
559
|
+
const m = buildManifest({
|
|
560
|
+
exporterVersion: '0.1.59',
|
|
561
|
+
dshVersion: '0.1.0-rc.6',
|
|
562
|
+
platform: 'win32',
|
|
563
|
+
arch: 'x64',
|
|
564
|
+
sections: sectionsAllFalse() as unknown as Manifest['sections'],
|
|
565
|
+
containsSecrets: false,
|
|
566
|
+
encrypted: false,
|
|
567
|
+
encryption: null,
|
|
568
|
+
exportedAt: '2026-08-14T12:00:00.000Z',
|
|
569
|
+
foreign,
|
|
570
|
+
});
|
|
571
|
+
assert.deepEqual(m.foreign, validForeign(), 'buildManifest 必须原样写入(不裁剪、不补默认值)');
|
|
572
|
+
assert.deepEqual(validateManifest(m), [], '合法 foreign 不得产生任何 issue');
|
|
573
|
+
const reparsed = parseManifest(serializeManifest(m));
|
|
574
|
+
assert.deepEqual(reparsed.foreign, validForeign(), '往返后溯源块逐字保留');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('F-03 foreign 合法 → 通过(零 error 零 warning),且 schema 不得拒绝该顶层字段', () => {
|
|
578
|
+
const m = mutate({ foreign: validForeign() });
|
|
579
|
+
assert.deepEqual(errorPaths(m), [], '合法 foreign 不得有 error');
|
|
580
|
+
assert.deepEqual(validateManifest(m), [], '合法 foreign 不得有 warning');
|
|
581
|
+
assert.deepEqual(schemaPaths(m), [], 'schema 对未声明的顶层字段保持开放(不得因此拒绝)');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test('F-04 foreign 存在但形状/字段类型不对 → 只 warning、绝不 error、parseManifest 不抛错', () => {
|
|
585
|
+
const cases: [string, unknown][] = [
|
|
586
|
+
['不是对象(字符串)', 'claude-code'],
|
|
587
|
+
['null', null],
|
|
588
|
+
['数组', []],
|
|
589
|
+
['缺 agent', { locations: ['.claude.json'] }],
|
|
590
|
+
['agent 类型错', { agent: 42 }],
|
|
591
|
+
['locations 元素非字符串', { agent: 'x', locations: ['.a', 7] }],
|
|
592
|
+
['sourceVersion 类型错', { agent: 'x', sourceVersion: 1 }],
|
|
593
|
+
['projects 不是数组', { agent: 'x', projects: 'D:/proj' }],
|
|
594
|
+
['credentialRefs 元素非字符串', { agent: 'x', credentialRefs: [{ ref: 'a' }] }],
|
|
595
|
+
['skipped 缺 count', { agent: 'x', skipped: [{ code: 'unsupported-hooks' }] }],
|
|
596
|
+
['plugins 元素缺 name', { agent: 'x', plugins: [{ version: '1.0.0' }] }],
|
|
597
|
+
];
|
|
598
|
+
for (const [name, value] of cases) {
|
|
599
|
+
const m = mutate({ foreign: value });
|
|
600
|
+
assert.deepEqual(errorPaths(m), [], `[${name}] foreign 绝不允许产生 error`);
|
|
601
|
+
assert.deepEqual(warningPaths(m), ['foreign'], `[${name}] 必须恰好一条 foreign warning(不逐字段刷屏)`);
|
|
602
|
+
assert.doesNotThrow(() => parseManifest(JSON.stringify(m)), `[${name}] 畸形容器不得阻断解析`);
|
|
603
|
+
}
|
|
604
|
+
});
|
package/src/schema/manifest.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { parseJsonSafe } from '../utils/json.ts';
|
|
6
6
|
import { CURRENT_SCHEMA_VERSION } from './versions.ts';
|
|
7
|
-
import type { Manifest, Platform, SectionId } from './types.ts';
|
|
7
|
+
import type { Manifest, ManifestForeignOrigin, Platform, SectionId } from './types.ts';
|
|
8
8
|
import { SECTION_IDS } from './config.ts';
|
|
9
9
|
import { schemaMsg } from './messages.ts';
|
|
10
10
|
|
|
@@ -24,6 +24,11 @@ export interface BuildManifestInput {
|
|
|
24
24
|
exportedAt?: string; // 测试可注入固定时间
|
|
25
25
|
/** 导出机的 DSH home(可选;见 Manifest.sourceHome,用于跨机基础路径重定基) */
|
|
26
26
|
sourceHome?: string;
|
|
27
|
+
/**
|
|
28
|
+
* 外部来源包的溯源块(可选;见 Manifest.foreign)。
|
|
29
|
+
* **只有显式传入时才写入** —— 缺省不产生该键,DSH 自产备份与旧包逐字不变。
|
|
30
|
+
*/
|
|
31
|
+
foreign?: ManifestForeignOrigin;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/** 构造 manifest(schemaVersion 恒为当前版本,集中于此) */
|
|
@@ -38,6 +43,7 @@ export function buildManifest(input: BuildManifestInput): Manifest {
|
|
|
38
43
|
},
|
|
39
44
|
exportedAt: input.exportedAt ?? new Date().toISOString(),
|
|
40
45
|
...(input.sourceHome !== undefined && input.sourceHome !== '' ? { sourceHome: input.sourceHome } : {}),
|
|
46
|
+
...(input.foreign !== undefined ? { foreign: input.foreign } : {}),
|
|
41
47
|
sections: input.sections,
|
|
42
48
|
security: {
|
|
43
49
|
containsSecrets: input.containsSecrets,
|
|
@@ -65,6 +71,53 @@ export function parseManifest(raw: string): Manifest {
|
|
|
65
71
|
|
|
66
72
|
export interface ManifestIssue { path: string; message: string; severity: 'error' | 'warning'; }
|
|
67
73
|
|
|
74
|
+
/**
|
|
75
|
+
* `manifest.foreign`(外部来源溯源块)的**宽容校验**。
|
|
76
|
+
*
|
|
77
|
+
* 铁律:本字段**绝不允许**成为拒绝一个包的理由 —— 旧包没有它、新包可能带畸形值,
|
|
78
|
+
* 两者都必须能继续导入。因此这里只产出 `warning`,且**只落一条**汇总(路径恒为 `foreign`),
|
|
79
|
+
* 出错字段名以机器码形式列在文案参数里(不逐字段刷屏、不新增用户可见文案)。
|
|
80
|
+
*/
|
|
81
|
+
function validateForeignOrigin(value: unknown): ManifestIssue[] {
|
|
82
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
83
|
+
return [{
|
|
84
|
+
path: 'foreign',
|
|
85
|
+
message: schemaMsg('schema.sectionDataInvalid', { section: 'manifest.foreign', issues: 'not-an-object' }),
|
|
86
|
+
severity: 'warning',
|
|
87
|
+
}];
|
|
88
|
+
}
|
|
89
|
+
const f = value as Record<string, unknown>;
|
|
90
|
+
const bad: string[] = [];
|
|
91
|
+
if (typeof f['agent'] !== 'string') bad.push('agent');
|
|
92
|
+
if (f['locations'] !== undefined && !isStringArray(f['locations'])) bad.push('locations');
|
|
93
|
+
if (f['sourceVersion'] !== undefined && typeof f['sourceVersion'] !== 'string') bad.push('sourceVersion');
|
|
94
|
+
if (f['projects'] !== undefined && !isStringArray(f['projects'])) bad.push('projects');
|
|
95
|
+
if (f['credentialRefs'] !== undefined && !isStringArray(f['credentialRefs'])) bad.push('credentialRefs');
|
|
96
|
+
if (f['skipped'] !== undefined && !isSkippedAggregates(f['skipped'])) bad.push('skipped');
|
|
97
|
+
if (f['plugins'] !== undefined && !isPluginSummaries(f['plugins'])) bad.push('plugins');
|
|
98
|
+
if (bad.length === 0) return [];
|
|
99
|
+
return [{
|
|
100
|
+
path: 'foreign',
|
|
101
|
+
message: schemaMsg('schema.sectionDataInvalid', { section: 'manifest.foreign', issues: bad.join(',') }),
|
|
102
|
+
severity: 'warning',
|
|
103
|
+
}];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isStringArray(value: unknown): boolean {
|
|
107
|
+
return Array.isArray(value) && value.every((v) => typeof v === 'string');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isSkippedAggregates(value: unknown): boolean {
|
|
111
|
+
return Array.isArray(value) && value.every((v) => v !== null && typeof v === 'object'
|
|
112
|
+
&& typeof (v as Record<string, unknown>)['code'] === 'string'
|
|
113
|
+
&& typeof (v as Record<string, unknown>)['count'] === 'number');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isPluginSummaries(value: unknown): boolean {
|
|
117
|
+
return Array.isArray(value) && value.every((v) => v !== null && typeof v === 'object'
|
|
118
|
+
&& typeof (v as Record<string, unknown>)['name'] === 'string');
|
|
119
|
+
}
|
|
120
|
+
|
|
68
121
|
/** 结构校验:字段类型 / sections 键集合 / security 形状 */
|
|
69
122
|
export function validateManifest(m: unknown): ManifestIssue[] {
|
|
70
123
|
const issues: ManifestIssue[] = [];
|
|
@@ -124,5 +177,8 @@ export function validateManifest(m: unknown): ManifestIssue[] {
|
|
|
124
177
|
}
|
|
125
178
|
}
|
|
126
179
|
|
|
180
|
+
// foreign 是**外部来源包**的溯源块:缺省 = 旧包(合法);形状/字段类型不对 = 仅 warning(绝不拒绝)。
|
|
181
|
+
if (obj['foreign'] !== undefined) issues.push(...validateForeignOrigin(obj['foreign']));
|
|
182
|
+
|
|
127
183
|
return issues;
|
|
128
184
|
}
|