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.
Files changed (302) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +401 -112
  26. package/lib/client.js +2899 -1525
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/ancestor.d.ts +22 -0
  82. package/lib/sync/ancestor.js +66 -2
  83. package/lib/sync/autosync-config.d.ts +73 -0
  84. package/lib/sync/autosync-config.js +89 -0
  85. package/lib/sync/autosync-scheduler.d.ts +80 -6
  86. package/lib/sync/autosync-scheduler.js +263 -40
  87. package/lib/sync/gist/gist-transport.d.ts +18 -1
  88. package/lib/sync/gist/gist-transport.js +42 -3
  89. package/lib/sync/gist/messages.d.ts +2 -0
  90. package/lib/sync/gist/messages.js +5 -0
  91. package/lib/sync/git/git-transport.d.ts +42 -3
  92. package/lib/sync/git/git-transport.js +146 -3
  93. package/lib/sync/merge.d.ts +47 -14
  94. package/lib/sync/merge.js +242 -222
  95. package/lib/sync/messages.d.ts +8 -0
  96. package/lib/sync/messages.js +19 -0
  97. package/lib/sync/risk.d.ts +19 -2
  98. package/lib/sync/risk.js +10 -3
  99. package/lib/sync/s3/messages.d.ts +6 -0
  100. package/lib/sync/s3/messages.js +13 -0
  101. package/lib/sync/s3/s3-transport.d.ts +9 -1
  102. package/lib/sync/s3/s3-transport.js +78 -6
  103. package/lib/sync/s3/sigv4.js +11 -2
  104. package/lib/sync/sync-engine.d.ts +212 -8
  105. package/lib/sync/sync-engine.js +514 -58
  106. package/lib/sync/sync-history.d.ts +11 -0
  107. package/lib/sync/sync-history.js +20 -0
  108. package/lib/sync/sync-state.d.ts +43 -0
  109. package/lib/sync/sync-state.js +44 -0
  110. package/lib/sync/sync-watcher.d.ts +76 -0
  111. package/lib/sync/sync-watcher.js +202 -0
  112. package/lib/sync/text-merge.d.ts +85 -0
  113. package/lib/sync/text-merge.js +513 -0
  114. package/lib/sync/transport.d.ts +48 -0
  115. package/lib/sync/transport.js +12 -0
  116. package/lib/sync/watch-scope.d.ts +38 -0
  117. package/lib/sync/watch-scope.js +95 -0
  118. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  119. package/lib/sync/webdav/webdav-transport.js +128 -11
  120. package/lib/ui/foreign-view.js +10 -0
  121. package/lib/ui/i18n.d.ts +9 -0
  122. package/lib/ui/i18n.js +22 -0
  123. package/lib/ui/rollback-view.d.ts +12 -0
  124. package/lib/ui/rollback-view.js +2 -0
  125. package/lib/ui/sync-settings-view.d.ts +47 -0
  126. package/lib/ui/sync-settings-view.js +24 -0
  127. package/lib/ui/types.d.ts +21 -0
  128. package/lib/utils/env-lock.d.ts +1 -0
  129. package/lib/utils/env-lock.js +7 -4
  130. package/package.json +6 -6
  131. package/src/adapters/credentials.ts +22 -4
  132. package/src/adapters/file-collection.ts +2 -1
  133. package/src/adapters/mcp.test.ts +235 -1
  134. package/src/adapters/mcp.ts +276 -5
  135. package/src/adapters/messages.ts +7 -0
  136. package/src/adapters/plugin-files.ts +2 -1
  137. package/src/adapters/plugins.test.ts +176 -2
  138. package/src/adapters/plugins.ts +107 -21
  139. package/src/adapters/pnpm-workspace.test.ts +89 -1
  140. package/src/adapters/pnpm-workspace.ts +127 -2
  141. package/src/adapters/prompts.ts +20 -3
  142. package/src/adapters/providers.ts +62 -4
  143. package/src/adapters/settings.ts +16 -3
  144. package/src/adapters/skills.test.ts +141 -0
  145. package/src/adapters/skills.ts +58 -2
  146. package/src/adapters/ui.ts +16 -3
  147. package/src/adapters/units-entries.test.ts +190 -0
  148. package/src/adapters/units.ts +50 -0
  149. package/src/adapters/workspaces.ts +2 -1
  150. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  151. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  152. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  153. package/src/client/common/Skeleton.tsx +32 -0
  154. package/src/client/common/api-base.test.ts +36 -0
  155. package/src/client/common/http.test.ts +12 -1
  156. package/src/client/common/http.ts +8 -2
  157. package/src/client/common/routes.ts +46 -3
  158. package/src/client/config-manager.module.css +321 -11
  159. package/src/client/home/HomePanel.tsx +14 -6
  160. package/src/client/import/ForeignImportView.tsx +3 -1
  161. package/src/client/import/import-wizard-steps.tsx +2 -1
  162. package/src/client/library/ArtifactRow.tsx +12 -2
  163. package/src/client/library/LibraryActions.tsx +6 -3
  164. package/src/client/library/LibraryPanel.tsx +21 -3
  165. package/src/client/library/RestorePlanView.tsx +4 -2
  166. package/src/client/locales.ts +3 -0
  167. package/src/client/market/market-api.test.ts +3 -2
  168. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  169. package/src/client/recovery/RollbackCard.tsx +7 -2
  170. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  171. package/src/client/recovery/recovery-locales.ts +20 -2
  172. package/src/client/recovery/recovery-view.test.ts +39 -1
  173. package/src/client/recovery/recovery-view.ts +23 -1
  174. package/src/client/run-store.test.ts +1 -1
  175. package/src/client/run-store.ts +4 -0
  176. package/src/client/sync/AutosyncCard.tsx +49 -6
  177. package/src/client/sync/MergeModal.tsx +220 -0
  178. package/src/client/sync/MergePreviewView.tsx +306 -0
  179. package/src/client/sync/SyncActivityBar.tsx +31 -0
  180. package/src/client/sync/SyncConfirmView.tsx +2 -1
  181. package/src/client/sync/SyncPanel.tsx +130 -87
  182. package/src/client/sync/SyncProbeRow.tsx +162 -0
  183. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  184. package/src/client/sync/WorkDirCard.tsx +44 -6
  185. package/src/client/sync/history-model.ts +4 -1
  186. package/src/client/sync/merge-view.test.ts +248 -0
  187. package/src/client/sync/merge-view.ts +398 -0
  188. package/src/client/sync/probe-view.test.ts +90 -0
  189. package/src/client/sync/probe-view.ts +63 -0
  190. package/src/client/sync/remote-device-view.test.ts +35 -0
  191. package/src/client/sync/remote-device-view.ts +25 -0
  192. package/src/client/sync/sync-api.test.ts +5 -3
  193. package/src/client/sync/sync-api.ts +212 -2
  194. package/src/client/sync/sync-entry.test.ts +78 -0
  195. package/src/client/sync/sync-entry.ts +53 -0
  196. package/src/client/sync/sync-locales.ts +234 -2
  197. package/src/client/sync/sync-view.test.ts +18 -1
  198. package/src/client/sync/sync-view.ts +20 -0
  199. package/src/core/backup.test.ts +41 -0
  200. package/src/core/backup.ts +20 -0
  201. package/src/core/mcp-connector-store.test.ts +265 -0
  202. package/src/core/mcp-connector-store.ts +516 -0
  203. package/src/core/messages-core.ts +4 -0
  204. package/src/core/messages.ts +46 -0
  205. package/src/core/phase3-host.ts +18 -1
  206. package/src/core/phase3-production-integration.test.ts +4 -0
  207. package/src/core/plugin-cli.test.ts +26 -0
  208. package/src/core/plugin-cli.ts +30 -4
  209. package/src/core/recovery-orchestrator.test.ts +104 -5
  210. package/src/core/recovery-orchestrator.ts +74 -17
  211. package/src/core/rollback-activity.test.ts +311 -0
  212. package/src/core/rollback-activity.ts +367 -0
  213. package/src/core/rollback-cross-session.test.ts +694 -0
  214. package/src/core/rollback-interrupted.test.ts +137 -0
  215. package/src/core/rollback-ledger.ts +32 -0
  216. package/src/core/rollback-merge.test.ts +130 -0
  217. package/src/core/rollback-merge.ts +78 -14
  218. package/src/core/rollback-point.ts +468 -91
  219. package/src/core/rollback-undo-journal.test.ts +508 -0
  220. package/src/core/rollback-undo-journal.ts +571 -0
  221. package/src/core/self-update.ts +2 -5
  222. package/src/core/text-fidelity.test.ts +189 -0
  223. package/src/core/text-fidelity.ts +197 -0
  224. package/src/core/types.ts +11 -3
  225. package/src/core/update-check.ts +2 -5
  226. package/src/core/validator.ts +14 -0
  227. package/src/foreign/bundle-origin.test.ts +265 -0
  228. package/src/foreign/bundle.ts +45 -5
  229. package/src/foreign/claude-code.test.ts +268 -0
  230. package/src/foreign/claude-code.ts +406 -25
  231. package/src/foreign/claude-plugins.test.ts +234 -0
  232. package/src/foreign/claude-sessions.test.ts +141 -0
  233. package/src/foreign/claude-sessions.ts +179 -23
  234. package/src/foreign/file-budget.test.ts +4 -1
  235. package/src/foreign/read-claude-code.test.ts +320 -0
  236. package/src/foreign/read-claude-code.ts +490 -71
  237. package/src/foreign/read-claude-plugins.ts +465 -0
  238. package/src/foreign/registry.ts +25 -2
  239. package/src/foreign/session-ir.test.ts +65 -0
  240. package/src/foreign/truth-table.test.ts +81 -0
  241. package/src/foreign/truth-table.ts +6 -3
  242. package/src/foreign/types.ts +162 -2
  243. package/src/index.facade.test.ts +26 -4
  244. package/src/index.ts +76 -19
  245. package/src/market/git-file-writer.test.ts +4 -0
  246. package/src/market/git-file-writer.ts +7 -1
  247. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  248. package/src/profiles/dsh-profile-launcher.ts +56 -1
  249. package/src/profiles/dsh-profile-shared.ts +8 -2
  250. package/src/routes/foreign.ts +8 -1
  251. package/src/routes/index.ts +1 -1
  252. package/src/routes/kit.ts +2 -2
  253. package/src/routes/sync.ts +176 -9
  254. package/src/schema/manifest-schema.test.ts +87 -2
  255. package/src/schema/manifest.ts +57 -1
  256. package/src/schema/types.ts +45 -2
  257. package/src/sync/ancestor.ts +76 -2
  258. package/src/sync/autosync-config.test.ts +73 -1
  259. package/src/sync/autosync-config.ts +124 -0
  260. package/src/sync/autosync-risk-tier.test.ts +38 -2
  261. package/src/sync/autosync-scheduler.test.ts +264 -12
  262. package/src/sync/autosync-scheduler.ts +296 -44
  263. package/src/sync/gist/gist-conditional-write.test.ts +203 -0
  264. package/src/sync/gist/gist-transport.ts +53 -5
  265. package/src/sync/gist/messages.ts +5 -0
  266. package/src/sync/git/git-probe.test.ts +80 -0
  267. package/src/sync/git/git-transport.test.ts +185 -0
  268. package/src/sync/git/git-transport.ts +178 -5
  269. package/src/sync/merge.test.ts +188 -55
  270. package/src/sync/merge.ts +282 -224
  271. package/src/sync/messages.ts +19 -0
  272. package/src/sync/risk.ts +29 -5
  273. package/src/sync/s3/messages.ts +13 -0
  274. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  275. package/src/sync/s3/s3-transport.test.ts +2 -1
  276. package/src/sync/s3/s3-transport.ts +87 -9
  277. package/src/sync/s3/sigv4.test.ts +4 -1
  278. package/src/sync/s3/sigv4.ts +11 -2
  279. package/src/sync/sync-device-identity.test.ts +267 -0
  280. package/src/sync/sync-engine.test.ts +410 -19
  281. package/src/sync/sync-engine.ts +602 -57
  282. package/src/sync/sync-history.test.ts +16 -1
  283. package/src/sync/sync-history.ts +26 -0
  284. package/src/sync/sync-state.test.ts +64 -0
  285. package/src/sync/sync-state.ts +79 -0
  286. package/src/sync/sync-watcher.test.ts +197 -0
  287. package/src/sync/sync-watcher.ts +246 -0
  288. package/src/sync/text-merge.test.ts +358 -0
  289. package/src/sync/text-merge.ts +554 -0
  290. package/src/sync/transport.ts +57 -0
  291. package/src/sync/watch-scope.test.ts +122 -0
  292. package/src/sync/watch-scope.ts +120 -0
  293. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  294. package/src/sync/webdav/webdav-transport.ts +137 -14
  295. package/src/ui/foreign-view.test.ts +34 -0
  296. package/src/ui/foreign-view.ts +10 -0
  297. package/src/ui/i18n.ts +22 -0
  298. package/src/ui/rollback-view.ts +14 -0
  299. package/src/ui/sync-settings-view.test.ts +28 -1
  300. package/src/ui/sync-settings-view.ts +43 -0
  301. package/src/ui/types.ts +22 -0
  302. package/src/utils/env-lock.ts +8 -5
@@ -12,6 +12,12 @@
12
12
  * 互不干扰;同一时刻至多执行一个通道的 runOnce(runs.register('autosync') 全局防重,
13
13
  * 避免两个引擎并发写本地配置),另一通道的定时触发在本轮结束后自然补跑。
14
14
  *
15
+ * 「真·自动」(VS Code 式)两条即时通道(realtime=true 时生效):
16
+ * - **本地改动即时上行**:SyncWatcher 监听本机可同步分区(watch-scope.ts 的清单,已排除
17
+ * sessions/、sync-autosync.json 等自写回声路径)→ 去抖后 trigger(channel,'local-change');
18
+ * - **远端改动即时下行**:每 remoteCheckMs(缺省 60s,可 15s–1h)廉价探测一次远端是否有新快照
19
+ * → trigger(channel,'remote-poll')。定时器 interval 只作兜底。
20
+ *
15
21
  * 核心逻辑(runOnce):
16
22
  * - 读该通道配置;若 !enabled → return
17
23
  * - runs.register('autosync') 防重复;同 kind running → 跳过
@@ -19,11 +25,16 @@
19
25
  * → 记 skipped(未配置) → return
20
26
  * - Phase A: engine.merge() 三方合并 → 判定 needsReview(冲突/缺失依赖/Install/Error)
21
27
  * - 冲突 → 只挡**下行**:跳过 Phase B(绝不写本地)+ 记 conflictedSections[],
22
- * 但**继续**走到 Phase C 做纯上行导出(issue #79:远端冲突不该连坐「把本机配置推上去当备份」)
28
+ * 但**继续**走到 Phase C(issue #79:远端冲突不该连坐「把本机配置推上去当备份」)
23
29
  * - Phase B: 无冲突 → 按 risk.ts 的 classifyMergePlan 分流(audit-sync sync-F5):
24
- * 低风险项自动应用;medium/high 项**不自动应用**并进 conflictedSections(待人工确认,绝不静默)
30
+ * 低风险自动应用;medium 仅当 autoApplyTier='medium'(缺省)自动应用;
31
+ * high/未注册/冲突项**不自动应用**并进 conflictedSections(待人工确认,绝不静默)
25
32
  * → engine.applyMergePlan(autoApply) 写入本地
26
- * - Phase C: 完整双向 → engine.push() 上传(本轮有冲突且推送成功 → 状态 partial,不谎报 success)
33
+ * - Phase C: 完整双向 → engine.push() 上传。两处策略:
34
+ * ① 无冲突且推送被远端推进挡下 → skipped/partial + skipReason='conflict'(等人工);
35
+ * ② conflictPolicy='local'(缺省)且本轮有冲突 → **不写本地**,改 engine.push({force:true})
36
+ * 把本机版本推成新的远端最新(被覆盖的远端旧版本仍在远端历史快照里可回滚),
37
+ * 状态记 partial + skipReason='conflict-kept-local'(绝不谎报 success)。
27
38
  * - 收尾:写该通道 autosync-config(lastRunAt, lastRunStatus, consecutiveFailures,
28
39
  * lastRunHistoryId, lastRunMessage/跳过原因 —— 每轮覆盖上一轮,绝不残留旧错误)
29
40
  *
@@ -34,10 +45,12 @@
34
45
  import crypto from 'node:crypto';
35
46
  import { withMutationLock, LOCK_BLOCK_MESSAGE } from '../utils/env-lock.js';
36
47
  import { engineMsg } from './messages.js';
37
- import { readAutosyncConfig, writeAutosyncConfig } from './autosync-config.js';
48
+ import { readAutosyncConfig, writeAutosyncConfig, clampRemoteCheckMs, DEFAULT_AUTOSYNC_REALTIME, DEFAULT_AUTOSYNC_REMOTE_CHECK_MS, DEFAULT_AUTOSYNC_AUTO_APPLY_TIER, DEFAULT_AUTOSYNC_CONFLICT_POLICY, } from './autosync-config.js';
38
49
  import { readSyncConfigFor, isGitConfig, isWebDavConfig, isS3Config, isGistConfig, SYNC_CHANNELS } from './sync-config.js';
39
- import { readSyncHistory, appendAutosyncEntry } from './sync-history.js';
50
+ import { readSyncHistory, appendAutosyncEntry, markAutosyncNotified } from './sync-history.js';
40
51
  import { classifyMergePlan } from './risk.js';
52
+ import { SyncWatcher } from './sync-watcher.js';
53
+ import { buildWatchTargets } from './watch-scope.js';
41
54
  /** 间隔 → ms 换算(§4.3) */
42
55
  export function intervalToMs(interval) {
43
56
  const table = {
@@ -85,6 +98,22 @@ export class AutoSyncScheduler {
85
98
  lockCtxForJournal = null;
86
99
  /** 每通道一个定时器(git/webdav 各自 enabled 时独立排期)。 */
87
100
  timers = new Map();
101
+ /** realtime 通道的远端轮询定时器:与 interval 定时器**分开**,否则每次轮询都会重置兜底间隔。 */
102
+ remoteTimers = new Map();
103
+ /** 本地改动监听(只有 enabled && realtime 的通道才挂;全关 → null)。 */
104
+ watcher = null;
105
+ /** 当前需要监听的通道(setupWatchers 计算)。 */
106
+ realtimeChannels = [];
107
+ /** 运行期到达的触发:本轮收尾后立刻补跑(绝不并发写本地配置,也绝不丢)。 */
108
+ pendingTrigger = null;
109
+ /** 上次开始运行的时刻(trigger 的最小间隔合并用)。 */
110
+ lastRunAtMs = new Map();
111
+ /** 因最小间隔被推迟的触发(每通道至多一个)。 */
112
+ gapTimers = new Map();
113
+ homeDir;
114
+ profile;
115
+ makeWatcher;
116
+ minTriggerGapMs;
88
117
  stopped = false;
89
118
  running = false;
90
119
  constructor(opts) {
@@ -105,6 +134,10 @@ export class AutoSyncScheduler {
105
134
  this.phase3Recovery = opts.phase3Recovery;
106
135
  this.detectRemoteNew = opts.detectRemoteNew ?? defaultDetectRemoteNew;
107
136
  this.detectLocalChange = opts.detectLocalChange ?? defaultDetectLocalChange;
137
+ this.homeDir = opts.homeDir;
138
+ this.profile = opts.profile;
139
+ this.makeWatcher = opts.makeWatcher ?? defaultMakeWatcher;
140
+ this.minTriggerGapMs = opts.minTriggerGapMs ?? 3000;
108
141
  }
109
142
  /** 追加一条自动同步历史(自动带上触发通道;0x 安全:绝不覆盖调用方显式 transport)。 */
110
143
  async appendHistory(channel, entry) {
@@ -117,12 +150,22 @@ export class AutoSyncScheduler {
117
150
  this.refreshTimers();
118
151
  void this.startupRuns();
119
152
  }
120
- /** 停止:清全部定时器、标记不再调度;正在执行的任务允许自然结束。 */
153
+ /** 停止:清全部定时器、拆掉文件监听、标记不再调度;正在执行的任务允许自然结束。 */
121
154
  stop() {
122
155
  this.stopped = true;
123
156
  for (const timer of this.timers.values())
124
157
  this.clearTimer(timer);
125
158
  this.timers.clear();
159
+ for (const timer of this.remoteTimers.values())
160
+ this.clearTimer(timer);
161
+ this.remoteTimers.clear();
162
+ for (const timer of this.gapTimers.values())
163
+ this.clearTimer(timer);
164
+ this.gapTimers.clear();
165
+ this.pendingTrigger = null;
166
+ this.watcher?.stop();
167
+ this.watcher = null;
168
+ this.realtimeChannels = [];
126
169
  }
127
170
  /** 重新加载配置(路由 POST /sync/autosync 后调用;重排所有通道定时器)。 */
128
171
  async reload() {
@@ -131,6 +174,40 @@ export class AutoSyncScheduler {
131
174
  this.refreshTimers();
132
175
  }
133
176
  refreshTimers() {
177
+ this.scheduleIntervalTimers();
178
+ this.scheduleRemoteTimers();
179
+ void this.setupWatchers();
180
+ }
181
+ /**
182
+ * 外部触发一次运行(本地改动 / 远端探测 / 另一通道让位)。
183
+ *
184
+ * - 正在跑 → 记 pendingTrigger,本轮收尾后**立刻补跑**(不并发写本地配置,也绝不丢);
185
+ * - 距上次开始不足 minTriggerGapMs → 合并成一次(连续保存只跑一轮,每通道至多一个定时器)。
186
+ */
187
+ trigger(channel, reason = 'manual') {
188
+ if (this.stopped)
189
+ return;
190
+ if (this.running) {
191
+ this.pendingTrigger = { channel, reason };
192
+ return;
193
+ }
194
+ const waitMs = this.minTriggerGapMs - (this.now().getTime() - (this.lastRunAtMs.get(channel) ?? 0));
195
+ if (waitMs > 0) {
196
+ if (this.gapTimers.has(channel))
197
+ return;
198
+ const timer = this.setTimer(() => {
199
+ this.gapTimers.delete(channel);
200
+ this.trigger(channel, reason);
201
+ }, waitMs);
202
+ this.gapTimers.set(channel, timer);
203
+ return;
204
+ }
205
+ void this.runOnce(channel, { reason }).catch((err) => {
206
+ this.host.log.error(`自动同步触发失败(${channel}/${reason})`, { error: err instanceof Error ? err.message : String(err) });
207
+ });
208
+ }
209
+ /** 兜底间隔定时器:每个 enabled 通道按各自 interval 排一次性定时器,触发后只重排自己。 */
210
+ scheduleIntervalTimers() {
134
211
  for (const timer of this.timers.values())
135
212
  this.clearTimer(timer);
136
213
  this.timers.clear();
@@ -146,21 +223,86 @@ export class AutoSyncScheduler {
146
223
  this.timers.delete(channel);
147
224
  if (this.stopped)
148
225
  return;
149
- void this.runOnce(channel)
226
+ void this.runOnce(channel, { reason: 'interval' })
150
227
  .catch((err) => {
151
228
  this.host.log.error(`自动同步定时触发失败(${channel})`, { error: err instanceof Error ? err.message : String(err) });
152
229
  })
153
230
  .then(() => {
154
- // 本轮结束(成功 / 跳过 / 失败)后重新排定下一次;refreshTimers
155
- // 内部重读配置,若期间被关闭(enabled=false)则不再排期。
231
+ // 只重排 interval 定时器(远端轮询有自己的节奏,绝不互相重置)
156
232
  if (!this.stopped)
157
- this.refreshTimers();
233
+ this.scheduleIntervalTimers();
158
234
  });
159
235
  }, ms);
160
236
  this.timers.set(channel, timer);
161
237
  }).catch(() => { });
162
238
  }
163
239
  }
240
+ /**
241
+ * realtime 通道的远端轮询:按 remoteCheckMs(缺省 60s)排一次性定时器。
242
+ * 轮询只是「叫醒一轮 runOnce」—— 是否真的下载仍由 detectRemoteNew 判定,
243
+ * 所以哪怕每 60s 叫醒一次,无变化时也只会记一条 upToDate 而不会反复传输。
244
+ */
245
+ scheduleRemoteTimers() {
246
+ for (const timer of this.remoteTimers.values())
247
+ this.clearTimer(timer);
248
+ this.remoteTimers.clear();
249
+ const channels = SYNC_CHANNELS;
250
+ for (const channel of channels) {
251
+ void this.readConfig(channel).then((cfg) => {
252
+ if (this.stopped || !cfg.enabled || !(cfg.realtime ?? DEFAULT_AUTOSYNC_REALTIME))
253
+ return;
254
+ const ms = clampRemoteCheckMs(cfg.remoteCheckMs ?? DEFAULT_AUTOSYNC_REMOTE_CHECK_MS);
255
+ const timer = this.setTimer(() => {
256
+ this.remoteTimers.delete(channel);
257
+ if (this.stopped)
258
+ return;
259
+ this.trigger(channel, 'remote-poll');
260
+ // 固定节奏:轮询不等本轮跑完就排下一次(trigger 自己负责合并/排队)
261
+ if (!this.stopped)
262
+ this.scheduleRemoteTimers();
263
+ }, ms);
264
+ this.remoteTimers.set(channel, timer);
265
+ }).catch(() => { });
266
+ }
267
+ }
268
+ /** 按「enabled && realtime」重算监听集合:需要就挂、全关就拆(绝不留下空转句柄)。 */
269
+ async setupWatchers() {
270
+ const realtime = [];
271
+ for (const channel of SYNC_CHANNELS) {
272
+ try {
273
+ const cfg = await this.readConfig(channel);
274
+ if (cfg.enabled && (cfg.realtime ?? DEFAULT_AUTOSYNC_REALTIME))
275
+ realtime.push(channel);
276
+ }
277
+ catch { /* 读配置失败 → 该通道不挂监听 */ }
278
+ }
279
+ this.realtimeChannels = realtime;
280
+ if (realtime.length === 0) {
281
+ this.watcher?.stop();
282
+ this.watcher = null;
283
+ return;
284
+ }
285
+ if (this.homeDir === undefined || this.makeWatcher === undefined)
286
+ return; // 未接线(测试)→ 只靠定时器
287
+ if (this.watcher === null) {
288
+ this.watcher = this.makeWatcher({
289
+ homeDir: this.homeDir,
290
+ profile: this.profile,
291
+ log: this.host.log,
292
+ onFlush: (changed) => this.onLocalChange(changed),
293
+ });
294
+ this.watcher.start();
295
+ this.host.log.info(`自动同步已开启本地改动监听(${realtime.join('/')})`);
296
+ }
297
+ }
298
+ /** watcher 回调:任一批改动 → 让所有 realtime 通道各跑一轮(各自有变化检测,不会空转传输)。 */
299
+ onLocalChange(changed) {
300
+ if (this.stopped || changed.length === 0)
301
+ return;
302
+ this.host.log.debug(`检测到本地配置改动(${changed.length} 个路径)→ 触发自动同步`);
303
+ for (const channel of this.realtimeChannels)
304
+ this.trigger(channel, 'local-change');
305
+ }
164
306
  /** 启动触发下载合并(每个 enabled 通道;受各自 startupMinIntervalMs 阈值约束)。 */
165
307
  async startupRuns() {
166
308
  // 通道清单唯一来源 = SYNC_CHANNELS(此前同一数组在本文件写了两遍:漏改一处即某通道永不排期)
@@ -174,7 +316,7 @@ export class AutoSyncScheduler {
174
316
  this.host.log.info(`自动同步启动触发跳过(${channel}):距上次运行未达阈值`);
175
317
  continue;
176
318
  }
177
- await this.runOnce(channel, { startup: true });
319
+ await this.runOnce(channel, { startup: true, reason: 'startup' });
178
320
  }
179
321
  catch (err) {
180
322
  this.host.log.error(`启动触发下载合并失败(${channel})`, { error: err instanceof Error ? err.message : String(err) });
@@ -187,6 +329,16 @@ export class AutoSyncScheduler {
187
329
  * @param opts.startup - true 表示启动触发变体(只做 Phase A+B,不做 Phase C push)
188
330
  */
189
331
  async runOnce(channel, opts = {}) {
332
+ const result = await this.runOnceInternal(channel, opts);
333
+ // 收尾后补跑:运行期到达的触发(watcher 连续保存 / 远端轮询 / 定时器到期)不会丢。
334
+ // 放在内部实现之外(而不是 finally 里)是为了让锁与 run 账**完全释放之后**才开始下一轮。
335
+ const pending = this.pendingTrigger;
336
+ this.pendingTrigger = null;
337
+ if (pending !== null && !this.stopped)
338
+ this.trigger(pending.channel, pending.reason);
339
+ return result;
340
+ }
341
+ async runOnceInternal(channel, opts = {}) {
190
342
  if (this.running)
191
343
  return { status: 'skipped', direction: 'none', skipReason: 'running', historyId: '', consecutiveFailures: 0 };
192
344
  const cfg = await this.readConfig(channel);
@@ -194,6 +346,9 @@ export class AutoSyncScheduler {
194
346
  return { status: 'skipped', direction: 'none', skipReason: 'disabled', historyId: '', consecutiveFailures: cfg.consecutiveFailures };
195
347
  }
196
348
  this.running = true;
349
+ this.lastRunAtMs.set(channel, this.now().getTime());
350
+ // 本轮会写本机配置(apply)→ 暂停监听,别把自己的写入当成「用户改了配置」(否则自激循环)。
351
+ this.watcher?.suspend();
197
352
  const nowIso = this.now().toISOString();
198
353
  const historyId = `autosync-${crypto.randomUUID()}`;
199
354
  // runs 防重复:同 kind running → 跳过(内部语义,不打搅用户)
@@ -302,8 +457,13 @@ export class AutoSyncScheduler {
302
457
  // 加密快照只通过手动推送/拉取产生与消费;自动同步仅处理普通快照。
303
458
  // list 失败宽容处理:视为无加密快照,继续正常流程(网络问题由后续链路暴露为 failed)。
304
459
  let encryptedRemote = false;
460
+ // 本轮远端列表:加密快照检查与「远端是否有新快照」共用同一次 list。
461
+ // 每次 list 都是一次真实网络往返(git = `pull --ff-only`,webdav = PROPFIND/GET),
462
+ // 缺省 60s 轮询下多 list 一次 = 一天白跑 1440 次往返。
463
+ let remoteMetas = null;
305
464
  try {
306
465
  const metas = await engine.listSnapshots();
466
+ remoteMetas = metas;
307
467
  const latestMeta = metas.length > 0 ? metas[metas.length - 1] : null;
308
468
  encryptedRemote = latestMeta !== null && latestMeta.manifest.encrypted === true;
309
469
  }
@@ -326,7 +486,8 @@ export class AutoSyncScheduler {
326
486
  // - remoteNew:远端是否出现比本地祖先更新的快照 → 决定是否做下载合并(Phase A);
327
487
  // - localDirty:本地 portable 配置相对基线是否真的变了 → 决定是否上传(Phase C)。
328
488
  // 定时器只是兜底轮询;真正驱动是这两个「变化」信号。
329
- const remoteNew = await this.detectRemoteNew(engine);
489
+ // 列表拿不到(网络失败)时 remoteMetas 为 null → 检测自己再 list 一次,行为与改造前一致
490
+ const remoteNew = await this.detectRemoteNew(engine, remoteMetas ?? undefined);
330
491
  const localDirty = await this.detectLocalChange(engine);
331
492
  // 两端都无变化 → 什么都不做,记为 upToDate(不重复拉取/不空转)。
332
493
  if (!remoteNew && !localDirty) {
@@ -378,11 +539,15 @@ export class AutoSyncScheduler {
378
539
  // 风险分级(audit-sync sync-F5)照旧只在无冲突时执行。
379
540
  if (conflictedSections.length === 0) {
380
541
  // 无冲突:按 risk.ts 的风险分级契约分流(audit-sync sync-F5)。
381
- // 此前用 buildAutoApplyPlan 无条件应用全部非冲突分区 → medium/high 分区(plugins/mcp/
382
- // workspaces 等)在无用户确认时被自动写进本地,与 classifyMergePlan 的既定口径
383
- // (低风险自动应用;medium/high → 待审)分叉,「首次同步强制预览」在自动路径上也形同虚设。
542
+ // autoApplyTier(缺省 'medium')= 「真·自动同步所有改动」的开关:low 档只自动落
543
+ // 低风险分区(settings/ui/providers/prompts/skills/agentPresets/agentInstructions/self),
544
+ // medium 档连 plugins / mcp 这两个中风险分区也自动落盘;**high 档永不自动**
545
+ // (类型上限即 medium:secrets/credentialsStatus/pluginData/pluginFiles 根本不在同步面内)。
384
546
  // firstSync 恒 false:真正的首次同步没有祖先基线,merge 已把两侧差异判成 conflict → 走人工确认。
385
- const apply = classifyMergePlan(mergePlan, { firstSync: false });
547
+ const apply = classifyMergePlan(mergePlan, {
548
+ firstSync: false,
549
+ maxAutoTier: cfg.autoApplyTier ?? DEFAULT_AUTOSYNC_AUTO_APPLY_TIER,
550
+ });
386
551
  // 待审项(medium/high:决策明确但不该自动应用)必须可见 —— 进历史 conflictedSections,
387
552
  // 绝不静默丢弃;真正的冲突已在上方记入 conflictedSections。
388
553
  deferredSections = apply.review.map((s) => s.id);
@@ -424,14 +589,45 @@ export class AutoSyncScheduler {
424
589
  // Phase C: push 上传(完整双向)—— 仅当本地真有改动才上传(§3.1);startup 变体不上传。
425
590
  if (!opts.startup && localDirty) {
426
591
  try {
592
+ /**
593
+ * 冲突策略(conflictPolicy,缺省 'local'):
594
+ * - 'ask':不强制,推送会被引擎的「远端已推进」前置检测挡下 → skipped/partial(conflict);
595
+ * - 'local':**不写本地**(下行被冲突挡下),但把本机版本强制推成新的远端最新。
596
+ * force 只跳过「基线是否落后」这一道检查,仍走正常快照上传与基线记录;
597
+ * 被覆盖的远端旧版本留在远端历史快照里,可手动回滚。
598
+ */
599
+ const forcePush = conflictedSections.length > 0 && (cfg.conflictPolicy ?? DEFAULT_AUTOSYNC_CONFLICT_POLICY) === 'local';
427
600
  // Phase C: 完整双向 → engine.push() 上传。P0-A:外部 push 包 intent journal。
428
- const rawPush = async () => engine.push();
601
+ const rawPush = async () => engine.push(forcePush ? { force: true } : {});
429
602
  const pushReport = (this.phase3Recovery !== undefined && this.lockCtxForJournal !== null)
430
603
  ? (await this.phase3Recovery.runExternalIntent({
431
604
  operationType: 'autosync-push', lockCtx: this.lockCtxForJournal,
432
605
  intent: { adapter: 'sync', ref: channel, kind: 'Push' }, fn: rawPush,
433
606
  })).result
434
607
  : await rawPush();
608
+ if (!pushReport.ok && pushReport.conflict === true) {
609
+ // 引擎前置检测拒绝写入:远端已被别的设备推进 —— 这是**有冲突待处理**,不是通道/凭据故障。
610
+ // 绝不记 failed(那会累加连续失败计数并对用户发通知,把「需要用『检查远程合并』处理」
611
+ // 谎报成「同步坏了」);错误摘要保留引擎给出的说明(用户能看见到底为什么没推上去)。
612
+ const message = pushReport.message ?? engineMsg('sync.autosyncPushFailed');
613
+ const conflictResult = {
614
+ status: conflictedSections.length > 0 ? 'partial' : 'skipped',
615
+ direction: appliedSections.length ? 'both' : 'push',
616
+ skipReason: 'conflict', error: message, historyId,
617
+ appliedSections,
618
+ ...(conflictedSections.length > 0 ? { conflictedSections } : {}),
619
+ consecutiveFailures: 0,
620
+ };
621
+ await this.appendHistory(channel, {
622
+ direction: appliedSections.length ? 'both' : 'push', status: conflictResult.status,
623
+ skipReason: 'conflict', error: message,
624
+ appliedSections,
625
+ ...(conflictedSections.length > 0 ? { conflictedSections } : {}),
626
+ createdAt: nowIso, failureCountAtRun: 0,
627
+ });
628
+ await this.writeFinalConfig(cfg, conflictResult, nowIso, historyId, channel);
629
+ return conflictResult;
630
+ }
435
631
  if (!pushReport.ok) {
436
632
  const error = pushReport.message ?? engineMsg('sync.autosyncPushFailed');
437
633
  const result = {
@@ -450,22 +646,34 @@ export class AutoSyncScheduler {
450
646
  }
451
647
  // issue #79:上行成功但下行被冲突挡下 → partial(绝不把「半个方向完成」报成 success,
452
648
  // 也绝不因为它报成 failed:网络与凭据都是好的,只是远端有内容要用户确认)。
649
+ // conflictPolicy='local' 时冲突已被「本机覆盖远端」消解,但远端那一版确实被盖掉了 →
650
+ // 同样记 partial,skipReason 换成 conflict-kept-local(界面据此说清「本机已覆盖远端」)。
651
+ const keptLocal = forcePush && conflictedSections.length > 0;
453
652
  const partial = conflictedSections.length > 0;
653
+ const skipReason = keptLocal
654
+ ? 'conflict-kept-local'
655
+ : partial
656
+ ? 'conflict'
657
+ : undefined;
658
+ const reportedConflicted = keptLocal
659
+ ? conflictedSections
660
+ : partial
661
+ ? conflictedSections
662
+ : deferredSections;
454
663
  const result = {
455
664
  status: partial ? 'partial' : 'success',
456
665
  direction: appliedSections.length ? 'both' : 'push',
457
666
  appliedSections, pushedSnapshotId: pushReport.snapshotId, historyId,
458
- ...(partial ? { skipReason: 'conflict' } : {}),
459
- ...(deferredSections.length > 0 ? { conflictedSections: deferredSections } : {}),
460
- ...(partial ? { conflictedSections } : {}),
667
+ ...(skipReason !== undefined ? { skipReason } : {}),
668
+ ...(reportedConflicted.length > 0 ? { conflictedSections: reportedConflicted } : {}),
461
669
  consecutiveFailures: 0,
462
670
  };
463
671
  await this.appendHistory(channel, {
464
672
  direction: appliedSections.length ? 'both' : 'push',
465
673
  status: partial ? 'partial' : 'success',
466
674
  appliedSections, pushedSnapshotId: pushReport.snapshotId,
467
- ...(partial ? { skipReason: 'conflict', conflictedSections } : {}),
468
- ...(!partial && deferredSections.length > 0 ? { conflictedSections: deferredSections } : {}),
675
+ ...(skipReason !== undefined ? { skipReason } : {}),
676
+ ...(reportedConflicted.length > 0 ? { conflictedSections: reportedConflicted } : {}),
469
677
  createdAt: nowIso, failureCountAtRun: 0,
470
678
  });
471
679
  await this.writeFinalConfig(cfg, result, nowIso, historyId, channel);
@@ -533,6 +741,7 @@ export class AutoSyncScheduler {
533
741
  }
534
742
  finally {
535
743
  this.running = false;
744
+ this.watcher?.resume();
536
745
  // 收尾 RunRegistry:不 finish 会让 autosync 的 running 记录滞留,期间任何再次 runOnce
537
746
  // 都会 register('autosync') → RunConflictError → 永远 skip(conflict),后台同步就此停摆。
538
747
  // 同一收敛点也必须在**主 try 之外**的早退路径上调用(见 finishRun 的注释)。
@@ -556,7 +765,8 @@ export class AutoSyncScheduler {
556
765
  */
557
766
  async writeFinalConfig(base, result, nowIso, historyId, channel) {
558
767
  const skipReason = result.status === 'failed' ? undefined : result.skipReason;
559
- const skipCount = skipReason !== 'conflict' || result.conflictedSections === undefined
768
+ const countsConflicts = skipReason === 'conflict' || skipReason === 'conflict-kept-local';
769
+ const skipCount = !countsConflicts || result.conflictedSections === undefined
560
770
  ? undefined
561
771
  : result.conflictedSections.length;
562
772
  await this.writeConfig(channel, {
@@ -570,22 +780,22 @@ export class AutoSyncScheduler {
570
780
  lastRunSkipCount: skipCount,
571
781
  });
572
782
  }
573
- /** 连续失败 ≥ 3 → 通知(host.log.warn)。 */
783
+ /**
784
+ * 连续失败 ≥ 3 → 通知(host.log.warn)。
785
+ *
786
+ * issue #105:通知的持久痕迹只留历史 entry 的 notifiedAt。
787
+ * 此前这里把通知文案写进 autosync-config.lastRunMessage —— 那是「本轮错误原文」的字段,
788
+ * 一个字段两种语义,界面就无法判断该显示什么;而且 entry.notifiedAt 只在内存对象上改、
789
+ * 从未落盘(sync-history 当时只有 append),通知事实其实没留下。现在两者都走一处:
790
+ * 通知文案只进日志,notifiedAt 落盘到历史记录。
791
+ */
574
792
  maybeNotify(failures, historyId, nowIso, channel) {
575
- if (failures >= 3) {
576
- this.host.log.warn(`自动同步连续失败 ${failures} 次,请检查仓库配置/凭据(${channel})`);
577
- // 记录 notifiedAt(更新历史 entry)
578
- void this.readHistoryFn().then(async (hist) => {
579
- const entry = hist.autosyncEntries.find((e) => e.createdAt === nowIso);
580
- if (entry) {
581
- entry.notifiedAt = nowIso;
582
- await this.writeConfig(channel, {
583
- ...(await this.readConfig(channel)),
584
- lastRunMessage: engineMsg('sync.autosyncConsecutiveFailureNotified', { count: failures }),
585
- });
586
- }
587
- }).catch(() => { });
588
- }
793
+ if (failures < 3)
794
+ return;
795
+ this.host.log.warn(`自动同步连续失败 ${failures} 次,请检查仓库配置/凭据(${channel})`);
796
+ // readHistory 走注入的 readHistoryFn(测试可替换),写入仍是原子落盘
797
+ void markAutosyncNotified(this.syncDir, nowIso, nowIso, { readHistory: this.readHistoryFn })
798
+ .catch(() => { });
589
799
  }
590
800
  }
591
801
  /**
@@ -636,12 +846,13 @@ export function syncIsConfigured(cfg) {
636
846
  /**
637
847
  * 缺省远端新快照检测(§3.2):engine 实现了 hasNewRemoteSnapshot() → 调用;
638
848
  * 否则(测试 mock 未实现)保守返回 true(假设有新生,保持旧行为=每次都尝试拉取)。
849
+ * `listed` 透传给引擎:本轮已 list 过就直接复用,不再多跑一次网络往返。
639
850
  */
640
- async function defaultDetectRemoteNew(engine) {
851
+ async function defaultDetectRemoteNew(engine, listed) {
641
852
  const fn = engine.hasNewRemoteSnapshot;
642
853
  if (typeof fn === 'function') {
643
854
  try {
644
- return await fn.call(engine);
855
+ return await fn.call(engine, listed);
645
856
  }
646
857
  catch {
647
858
  return true; // 检测失败保守视为有新生,避免漏拉
@@ -653,6 +864,18 @@ async function defaultDetectRemoteNew(engine) {
653
864
  * 缺省本地变化检测(§3.1):engine 实现了 hasLocalChanges() → 调用;
654
865
  * 否则(测试 mock 未实现)保守返回 true(假设有本地改动,保持旧行为=每次都尝试上传)。
655
866
  */
867
+ /**
868
+ * 缺省 watcher 工厂 = 真实 fs.watch(SyncWatcher)。
869
+ * targets 由 watch-scope 的静态清单派生(纯函数、不读磁盘),profile 只影响 profiles/ 下的路径。
870
+ */
871
+ function defaultMakeWatcher(opts) {
872
+ return new SyncWatcher({
873
+ homeDir: opts.homeDir,
874
+ targets: buildWatchTargets({ homeDir: opts.homeDir, profile: opts.profile }),
875
+ log: opts.log,
876
+ onFlush: opts.onFlush,
877
+ });
878
+ }
656
879
  async function defaultDetectLocalChange(engine) {
657
880
  const fn = engine.hasLocalChanges;
658
881
  if (typeof fn === 'function') {
@@ -1,10 +1,16 @@
1
1
  import type { MsgFunc } from '../../core/msg-types.ts';
2
2
  import { SyncTransportError } from '../transport.ts';
3
- import type { SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions } from '../transport.ts';
3
+ import type { SyncProbeResult, SyncRetryOptions, SyncSnapshot, SyncSnapshotMeta, SyncTransport, SyncTransportErrorOptions } from '../transport.ts';
4
4
  /** 缺省 GitHub API 根 */
5
5
  export declare const GIST_DEFAULT_API_BASE_URL = "https://api.github.com";
6
6
  /** 缺省文件前缀 */
7
7
  export declare const GIST_DEFAULT_FILE_PREFIX = "dsh-sync";
8
+ /**
9
+ * **gist 通道不做条件写**(issue #110,2026-10 实测):`PATCH /gists/{id}` 对**任何**条件请求头
10
+ * 都直接回 400 —— `Conditional request headers are not allowed in unsafe requests unless supported
11
+ * by the endpoint`,与 ETag 强弱无关(同一请求去掉 If-Match 即 200)。故本通道的 PATCH 一律不带
12
+ * `If-Match`,也**没有**写前并发保护;要并发保护请用 git / webdav / s3 通道(那里的索引写是条件写)。
13
+ */
8
14
  /** 请求选项:headers / body / 覆盖默认超时(ms;0 = 不超时) */
9
15
  export interface GistRequestOptions {
10
16
  headers?: Record<string, string>;
@@ -61,6 +67,12 @@ export declare class GistTransport implements SyncTransport {
61
67
  download(id: string): Promise<SyncSnapshot>;
62
68
  /** 删除远端快照(不存在视为成功):PATCH 置 null + 写回摘除后的索引。 */
63
69
  delete(id: string): Promise<void>;
70
+ /**
71
+ * 连通性探针(cloud-sync 借鉴项 ②③):令牌有效性(GET /user)→ gist 可读 → 快照数 →
72
+ * 归属提示(gist 里已有不属于本前缀的文件时给出 warning 级 hint,绝不自动删别人的文件)。
73
+ * 只读:不打任何 PATCH。
74
+ */
75
+ probe(): Promise<SyncProbeResult>;
64
76
  private assertSafeId;
65
77
  private gistUrl;
66
78
  private snapshotFileName;
@@ -70,6 +82,11 @@ export declare class GistTransport implements SyncTransport {
70
82
  private send;
71
83
  /** 读 gist(含全部文件条目);404 → 明确「gist 不存在或无权访问」 */
72
84
  private readGistFiles;
85
+ /**
86
+ * 读 gist 全量文件。响应 ETag **不用**:gist 的 PATCH 端点不接受任何条件请求头(issue #110 实测),
87
+ * 读到强/弱 ETag 都无处可用,故这里不再解析。
88
+ */
89
+ private readGist;
73
90
  /**
74
91
  * 取某个文件的内容:gist 载荷里直接有就用它;被 GitHub 截断(\`truncated\`)或内容缺失时
75
92
  * 回落 \`raw_url\` 重新下载(跨源跳转剥离 Authorization —— gh 的 raw 地址本身不可猜)。
@@ -152,7 +152,7 @@ export class GistTransport {
152
152
  this.assertSafeId(snapshot.id);
153
153
  const token = await this.tokenOnce();
154
154
  const meta = computeSnapshotMeta(snapshot);
155
- const files = await this.readGistFiles(token);
155
+ const { files } = await this.readGist(token);
156
156
  const idxRaw = await this.readFileText(files, this.indexFileName, token);
157
157
  const idxBefore = idxRaw === null ? [] : this.parseIndex(idxRaw, this.gistUrl(), token);
158
158
  const existing = idxBefore.find((m) => m.id === snapshot.id);
@@ -196,7 +196,7 @@ export class GistTransport {
196
196
  async delete(id) {
197
197
  this.assertSafeId(id);
198
198
  const token = await this.tokenOnce();
199
- const files = await this.readGistFiles(token);
199
+ const { files } = await this.readGist(token);
200
200
  const idxRaw = await this.readFileText(files, this.indexFileName, token);
201
201
  const idx = idxRaw === null ? [] : this.parseIndex(idxRaw, this.gistUrl(), token);
202
202
  const fileName = this.snapshotFileName(id);
@@ -216,6 +216,36 @@ export class GistTransport {
216
216
  }
217
217
  }
218
218
  /* ---------------- 内部实现 ---------------- */
219
+ /**
220
+ * 连通性探针(cloud-sync 借鉴项 ②③):令牌有效性(GET /user)→ gist 可读 → 快照数 →
221
+ * 归属提示(gist 里已有不属于本前缀的文件时给出 warning 级 hint,绝不自动删别人的文件)。
222
+ * 只读:不打任何 PATCH。
223
+ */
224
+ async probe() {
225
+ const token = await this.tokenOnce();
226
+ const userUrl = this.o.apiBaseUrl + '/user';
227
+ const me = await this.send('GET', userUrl, token);
228
+ if (me.status === 401 || me.status === 403) {
229
+ const body = await this.safeText(me);
230
+ throw new GistTransportError(await this.failText('GET', userUrl, me, token, body), this.classify(me.status, body));
231
+ }
232
+ if (!me.ok) {
233
+ const body = await this.safeText(me);
234
+ throw new GistTransportError(await this.failText('GET', userUrl, me, token, body), this.classify(me.status, body));
235
+ }
236
+ const { files } = await this.readGist(token);
237
+ const idxRaw = await this.readFileText(files, this.indexFileName, token);
238
+ const count = idxRaw === null ? 0 : this.parseIndex(idxRaw, this.gistUrl(), token).length;
239
+ const prefix = this.o.filePrefix + '-';
240
+ const foreign = Object.keys(files).filter((name) => !name.startsWith(prefix)).length;
241
+ return {
242
+ message: this.o.msg('sync.gist.probeOk', { count: String(count) }),
243
+ snapshotCount: count,
244
+ ...(foreign > 0
245
+ ? { hint: this.o.msg('sync.gist.probeForeignContent', { count: String(foreign), prefix: this.o.filePrefix }) }
246
+ : {}),
247
+ };
248
+ }
219
249
  assertSafeId(id) {
220
250
  if (typeof id !== 'string' || !SAFE_ID_RE.test(id) || RESERVED_IDS.has(id)) {
221
251
  throw new GistTransportError(this.o.msg('sync.gist.invalidSnapshotId', { id: JSON.stringify(id) }));
@@ -245,6 +275,8 @@ export class GistTransport {
245
275
  'content-type': 'application/json',
246
276
  'X-GitHub-Api-Version': GITHUB_API_VERSION,
247
277
  ...(token !== '' ? { authorization: 'Bearer ' + token } : {}),
278
+ // 附加请求头(本通道当前不注入条件头:gist 端点拒绝任何条件请求头,见文首说明)
279
+ ...(opts.headers ?? {}),
248
280
  };
249
281
  try {
250
282
  return await this.o.request(method, url, {
@@ -265,6 +297,13 @@ export class GistTransport {
265
297
  }
266
298
  /** 读 gist(含全部文件条目);404 → 明确「gist 不存在或无权访问」 */
267
299
  async readGistFiles(token) {
300
+ return (await this.readGist(token)).files;
301
+ }
302
+ /**
303
+ * 读 gist 全量文件。响应 ETag **不用**:gist 的 PATCH 端点不接受任何条件请求头(issue #110 实测),
304
+ * 读到强/弱 ETag 都无处可用,故这里不再解析。
305
+ */
306
+ async readGist(token) {
268
307
  const url = this.gistUrl();
269
308
  const res = await this.send('GET', url, token);
270
309
  if (res.status === 404) {
@@ -285,7 +324,7 @@ export class GistTransport {
285
324
  if (files === null || typeof files !== 'object' || Array.isArray(files)) {
286
325
  throw new GistTransportError(this.o.msg('sync.gist.indexInvalid', { url, err: 'files missing' }), { kind: 'protocol', retryable: false });
287
326
  }
288
- return files;
327
+ return { files: files };
289
328
  }
290
329
  /**
291
330
  * 取某个文件的内容:gist 载荷里直接有就用它;被 GitHub 截断(\`truncated\`)或内容缺失时
@@ -16,6 +16,8 @@ export declare const gistZh: {
16
16
  readonly 'sync.gist.timeout': '{method} {url} 请求超时({timeout}ms)';
17
17
  readonly 'sync.gist.indexInvalid': '{url} 解析失败或结构非法: {err}';
18
18
  readonly 'sync.gist.snapshotInvalid': '快照 {id} 解析失败或结构非法: {err}';
19
+ readonly 'sync.gist.probeOk': '连接正常(Gist 可读写,远端 {count} 份快照)';
20
+ readonly 'sync.gist.probeForeignContent': '该 Gist 里已有 {count} 个不属于本插件的文件:请确认这是你的同步专用 Gist(前缀 {prefix}),否则可能覆盖别人的数据';
19
21
  };
20
22
  export declare const gistEn: Record<keyof typeof gistZh, string>;
21
23
  /** 缺省(zh)Gist 通道翻译器。 */
@@ -22,6 +22,9 @@ export const gistZh = {
22
22
  'sync.gist.timeout': '{method} {url} 请求超时({timeout}ms)',
23
23
  'sync.gist.indexInvalid': '{url} 解析失败或结构非法: {err}',
24
24
  'sync.gist.snapshotInvalid': '快照 {id} 解析失败或结构非法: {err}',
25
+ // ---- cloud-sync 借鉴项 ②③:探针 / 归属提示(①条件写已按 issue #110 移除,见 gist-transport.ts)----
26
+ 'sync.gist.probeOk': '连接正常(Gist 可读写,远端 {count} 份快照)',
27
+ 'sync.gist.probeForeignContent': '该 Gist 里已有 {count} 个不属于本插件的文件:请确认这是你的同步专用 Gist(前缀 {prefix}),否则可能覆盖别人的数据',
25
28
  };
26
29
  export const gistEn = {
27
30
  'sync.gist.gistIdRequired': 'gistId must be a non-empty string (create a (secret) gist on GitHub and paste its id)',
@@ -40,6 +43,8 @@ export const gistEn = {
40
43
  'sync.gist.timeout': '{method} {url} request timed out ({timeout}ms)',
41
44
  'sync.gist.indexInvalid': '{url} failed to parse or is structurally invalid: {err}',
42
45
  'sync.gist.snapshotInvalid': 'Snapshot {id} failed to parse or is structurally invalid: {err}',
46
+ 'sync.gist.probeOk': 'Connection OK (gist readable and writable, {count} remote snapshots)',
47
+ 'sync.gist.probeForeignContent': 'This gist already contains {count} files that are not ours: confirm it is your dedicated sync gist (prefix {prefix}) or you may overwrite someone else’s data',
43
48
  };
44
49
  /** 缺省(zh)Gist 通道翻译器。 */
45
50
  export const gistMsg = makeCatalogMsg(gistZh, gistEn, 'zh');