dsh-config-manager 0.1.20 → 0.1.21

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 (54) hide show
  1. package/lib/client.d.ts +312 -46
  2. package/lib/client.js +1172 -205
  3. package/lib/client.js.map +1 -1
  4. package/lib/core/run-registry.d.ts +2 -2
  5. package/lib/core/run-registry.js +5 -1
  6. package/lib/core/run-registry.js.map +1 -1
  7. package/lib/index.js +292 -19
  8. package/lib/index.js.map +1 -1
  9. package/lib/sync/autosync-config.d.ts +34 -0
  10. package/lib/sync/autosync-config.js +116 -0
  11. package/lib/sync/autosync-config.js.map +1 -0
  12. package/lib/sync/autosync-scheduler.d.ts +95 -0
  13. package/lib/sync/autosync-scheduler.js +379 -0
  14. package/lib/sync/autosync-scheduler.js.map +1 -0
  15. package/lib/sync/sync-engine.d.ts +59 -4
  16. package/lib/sync/sync-engine.js +138 -12
  17. package/lib/sync/sync-engine.js.map +1 -1
  18. package/lib/sync/sync-history.d.ts +39 -0
  19. package/lib/sync/sync-history.js +88 -0
  20. package/lib/sync/sync-history.js.map +1 -0
  21. package/lib/sync/sync-session.d.ts +39 -0
  22. package/lib/sync/sync-session.js +54 -0
  23. package/lib/sync/sync-session.js.map +1 -0
  24. package/lib/ui/i18n.d.ts +63 -0
  25. package/lib/ui/i18n.js +130 -1
  26. package/lib/ui/i18n.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/client/config-manager.module.css +18 -0
  29. package/src/client/sync/SyncConfirmView.tsx +301 -0
  30. package/src/client/sync/SyncHistoryView.tsx +107 -25
  31. package/src/client/sync/SyncSettingsView.tsx +226 -83
  32. package/src/client/sync/history-model.test.ts +82 -0
  33. package/src/client/sync/history-model.ts +89 -2
  34. package/src/client/sync/sync-api.test.ts +155 -0
  35. package/src/client/sync/sync-api.ts +208 -16
  36. package/src/client/sync/sync-locales.ts +162 -1
  37. package/src/client/sync/sync-view-v2.test.ts +131 -0
  38. package/src/client/sync/sync-view.ts +151 -4
  39. package/src/core/run-registry.ts +7 -3
  40. package/src/index.ts +314 -22
  41. package/src/sync/autosync-config.test.ts +93 -0
  42. package/src/sync/autosync-config.ts +137 -0
  43. package/src/sync/autosync-scheduler.test.ts +191 -0
  44. package/src/sync/autosync-scheduler.ts +443 -0
  45. package/src/sync/sync-engine.test.ts +107 -7
  46. package/src/sync/sync-engine.ts +176 -14
  47. package/src/sync/sync-history.test.ts +85 -0
  48. package/src/sync/sync-history.ts +126 -0
  49. package/src/sync/sync-session.test.ts +137 -0
  50. package/src/sync/sync-session.ts +76 -0
  51. package/src/ui/i18n.ts +132 -3
  52. package/src/client/sync/SyncPullPreviewView.test.ts +0 -54
  53. package/src/client/sync/SyncPullPreviewView.tsx +0 -165
  54. package/src/client/sync/pull-preview-model.ts +0 -44
package/src/index.ts CHANGED
@@ -68,7 +68,7 @@ import {
68
68
  } from './core/plugin-cli.ts'
69
69
  import type {
70
70
  ConfigAdapter, CredentialsFacade, FileSystemFacade, HostContext, ImportDecisions,
71
- ImportPlan, NamespaceInfo, PatchFileFacade, PluginInfo, PluginsFacade,
71
+ ImportPlan, NamespaceInfo, PatchFileFacade, PlanItemKind, PluginInfo, PluginsFacade,
72
72
  SettingsFacade, WorkspaceFacade,
73
73
  } from './core/types.ts'
74
74
  import { createAdapters, USER_PATCH_FILE } from './adapters/index.ts'
@@ -77,9 +77,13 @@ import { createHardenedZipParser } from './security/zip-security.ts'
77
77
  import { GitTransport } from './sync/git/git-transport.ts'
78
78
  import { DeviceFlowStore, GitHubAuthClient } from './sync/github-auth.ts'
79
79
  import { SyncEngine } from './sync/sync-engine.ts'
80
+ import { SyncSessionStore } from './sync/sync-session.ts'
81
+ import { AutoSyncScheduler } from './sync/autosync-scheduler.ts'
82
+ import { defaultAutosyncConfig, readAutosyncConfig, writeAutosyncConfig } from './sync/autosync-config.ts'
83
+ import type { AutosyncInterval, AutosyncRunStatus } from './sync/autosync-config.ts'
84
+ import { appendAutosyncEntry, readSyncHistory } from './sync/sync-history.ts'
80
85
  import { loadSyncState, saveSyncState } from './sync/sync-state.ts'
81
86
  import { readSyncConfig, writeSyncConfig, validateRepoUrl } from './sync/sync-config.ts'
82
- import { classifyMergePlan } from './sync/risk.ts'
83
87
  import { MANIFEST_FILE, parseManifest } from './schema/manifest.ts'
84
88
  import { SECTION_IDS } from './schema/config.ts'
85
89
  import type { Manifest, SectionId, WorkspaceRecord } from './schema/types.ts'
@@ -152,8 +156,13 @@ const API = {
152
156
  syncGithubCancel: '/api/dsh-config-manager/sync/github/cancel',
153
157
  // P2:同步历史 / 自动应用 / 一键回滚
154
158
  syncHistory: '/api/dsh-config-manager/sync/history',
155
- syncApply: '/api/dsh-config-manager/sync/apply',
156
159
  syncRollback: '/api/dsh-config-manager/sync/rollback',
160
+ // m-sync-v2:一键同步(差异确认会话)+ 自动同步 + 历史快照
161
+ syncSnapshotsList: '/api/dsh-config-manager/sync/snapshots-list',
162
+ syncSync: '/api/dsh-config-manager/sync/sync',
163
+ syncApplyItems: '/api/dsh-config-manager/sync/apply-items',
164
+ syncCancel: '/api/dsh-config-manager/sync/cancel',
165
+ syncAutosync: '/api/dsh-config-manager/sync/autosync',
157
166
  } as const
158
167
 
159
168
  /**
@@ -828,6 +837,88 @@ export function writeSyncRouteError(res: ServerResponse, error: unknown): void {
828
837
  writeJson(res, 500, { error: message })
829
838
  }
830
839
 
840
+ /** 需要人工决策的 PlanItemKind(一键同步 needsReview 判定 + 逐项确认标记) */
841
+ const REVIEW_KINDS: ReadonlySet<PlanItemKind> = new Set([
842
+ 'Conflict', 'MissingSecret', 'MissingDependency', 'Install', 'Error', 'PathMapping',
843
+ ])
844
+
845
+ /** 一键同步差异项(client 逐项确认的最小契约;与 sync-api.ts SyncConfirmItem 对齐) */
846
+ interface SyncConfirmItem {
847
+ itemId: string
848
+ adapter: SectionId
849
+ kind: PlanItemKind
850
+ description: string
851
+ severity: 'info' | 'warning' | 'error'
852
+ defaultAdopt: boolean
853
+ adopt: boolean
854
+ conflict?: { path: string; kind: 'key' | 'file' | 'section'; local?: unknown; remote?: unknown; ancestor?: unknown; diff?: string }
855
+ target?: { adapter: SectionId; ref: string }
856
+ }
857
+
858
+ /** 把 ImportPlan 投影为逐项可确认的差异项(默认采用 Create/Update/Install;人工项默认不采用)。 */
859
+ function planToConfirmItems(plan: ImportPlan): SyncConfirmItem[] {
860
+ return plan.items.map((item) => {
861
+ const manual = REVIEW_KINDS.has(item.kind)
862
+ let conflict: SyncConfirmItem['conflict']
863
+ if (item.kind === 'Conflict') {
864
+ const c = (item as { conflict?: { path?: string; kind?: string; local?: unknown; remote?: unknown; ancestor?: unknown } }).conflict
865
+ conflict = {
866
+ path: c?.path ?? '$',
867
+ kind: c?.kind === 'file' ? 'file' : c?.kind === 'section' ? 'section' : 'key',
868
+ ...(c?.local !== undefined ? { local: c.local } : {}),
869
+ ...(c?.remote !== undefined ? { remote: c.remote } : {}),
870
+ ...(c?.ancestor !== undefined ? { ancestor: c.ancestor } : {}),
871
+ }
872
+ }
873
+ return {
874
+ itemId: item.id,
875
+ adapter: item.adapter,
876
+ kind: item.kind,
877
+ description: item.description,
878
+ severity: item.severity,
879
+ defaultAdopt: !manual,
880
+ adopt: !manual,
881
+ ...(conflict !== undefined ? { conflict } : {}),
882
+ ...(item.target !== undefined ? { target: item.target } : {}),
883
+ }
884
+ })
885
+ }
886
+
887
+ /** autosync interval 类型守卫 */
888
+ function isAutosyncInterval(v: unknown): v is AutosyncInterval {
889
+ return v === '5m' || v === '15m' || v === '30m' || v === '60m' || v === '6h' || v === '12h' || v === '24h'
890
+ }
891
+
892
+ /** 自动同步状态响应(GET /sync/autosync 与 POST 回填;读盘计算 elapsedMs)。 */
893
+ async function buildAutosyncStatus(dir: string): Promise<AutosyncStatusResponse> {
894
+ const cfg = await readAutosyncConfig(dir)
895
+ const elapsedMs = cfg.lastRunAt === undefined || cfg.lastRunAt === ''
896
+ ? -1
897
+ : Math.max(0, Date.now() - Date.parse(cfg.lastRunAt))
898
+ return {
899
+ enabled: cfg.enabled,
900
+ interval: cfg.interval,
901
+ ...(cfg.lastRunAt !== undefined ? { lastRunAt: cfg.lastRunAt } : {}),
902
+ ...(cfg.lastRunStatus !== undefined ? { lastRunStatus: cfg.lastRunStatus } : {}),
903
+ ...(cfg.lastRunMessage !== undefined ? { lastRunMessage: cfg.lastRunMessage } : {}),
904
+ consecutiveFailures: cfg.consecutiveFailures,
905
+ elapsedMs,
906
+ ...(cfg.lastRunHistoryId !== undefined ? { lastRunHistoryId: cfg.lastRunHistoryId } : {}),
907
+ }
908
+ }
909
+
910
+ /** GET /sync/autosync 响应类型(与 sync-api.ts AutosyncStatusResponse 对齐) */
911
+ interface AutosyncStatusResponse {
912
+ enabled: boolean
913
+ interval: AutosyncInterval
914
+ lastRunAt?: string
915
+ lastRunStatus?: AutosyncRunStatus
916
+ lastRunMessage?: string
917
+ consecutiveFailures: number
918
+ elapsedMs: number
919
+ lastRunHistoryId?: string
920
+ }
921
+
831
922
  /* -------------------------------------------------- restore 路由(M4) */
832
923
 
833
924
  /** POST /restore 请求体校验(纯函数;snapshotId 拒绝路径分隔符防 join 越界)。 */
@@ -1049,6 +1140,21 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
1049
1140
  });
1050
1141
  }
1051
1142
 
1143
+ /** 一键同步差异确认会话存储(进程内存;/sync/sync 预览 → /sync/apply-items 逐项执行解耦) */
1144
+ const syncSessions = new SyncSessionStore()
1145
+
1146
+ /** 自动同步后台调度器(宿主进程生命周期,不依赖浏览器) */
1147
+ let scheduler: AutoSyncScheduler | undefined
1148
+ scheduler = new AutoSyncScheduler({
1149
+ syncDir,
1150
+ host,
1151
+ makeSyncEngine,
1152
+ msg,
1153
+ runs,
1154
+ })
1155
+ // 启动:读 autosync-config;若 enabled 启动定时器;无条件执行一次「启动触发下载合并」(受阈值约束)
1156
+ scheduler.start()
1157
+
1052
1158
  return [
1053
1159
  // ------------------------------------------------------------- status
1054
1160
  {
@@ -1407,6 +1513,7 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
1407
1513
  lastSyncAt: state.lastSyncAt === '' ? undefined : state.lastSyncAt,
1408
1514
  sectionCount: Object.keys(state.sections).length,
1409
1515
  transport: state.transport,
1516
+ autosync: await buildAutosyncStatus(syncDir),
1410
1517
  })
1411
1518
  } catch (error) {
1412
1519
  writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
@@ -1627,21 +1734,28 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
1627
1734
  } catch { /* skip */ }
1628
1735
  }
1629
1736
  rows.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
1630
- writeJson(res, 200, rows)
1737
+ // 合并自动同步执行记录(sync-history.json)
1738
+ const hist = await readSyncHistory(syncDir)
1739
+ const merged = [
1740
+ ...rows.map((r) => ({ ...r, kind: 'apply' as const })),
1741
+ ...hist.autosyncEntries.map((e) => ({
1742
+ id: e.createdAt,
1743
+ createdAt: e.createdAt,
1744
+ kind: 'autosync' as const,
1745
+ autosync: e,
1746
+ })),
1747
+ ].sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
1748
+ writeJson(res, 200, { entries: merged })
1631
1749
  } catch (error) {
1632
1750
  writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
1633
1751
  }
1634
1752
  },
1635
1753
  },
1636
- // ------------------------------------------------------ sync/apply
1637
- // P2:应用自动应用计划。流程:
1638
- // 1) engine.merge 拉取远端 → 三方合并 → classifyMergePlan(firstSync)
1639
- // 2) engine.applyMergePlan(apply) → backup + Importer.executeImportPlan(rollbackOnError=true)
1640
- // 失败 → rollback + enqueueItems → return ApplyReport{ok:false,rolledBack:true,review}
1641
- // 3) 全成功 → recordBaseline + 标记 firstSyncCompleted
1754
+ // ------------------------------------------------------ sync/snapshots-list
1755
+ // m-sync-v2:远端历史快照列表(供「选择历史快照」下拉)。
1642
1756
  {
1643
1757
  kind: 'exact',
1644
- path: API.syncApply,
1758
+ path: API.syncSnapshotsList,
1645
1759
  handler: async (req, res) => {
1646
1760
  if (!guard(req, res, 'POST')) return
1647
1761
  const body = await readJsonBody(req)
@@ -1652,21 +1766,199 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
1652
1766
  try {
1653
1767
  const { repoUrl, gitBin } = await prepareSync(body)
1654
1768
  const engine = makeSyncEngine(repoUrl, gitBin)
1655
- const merge = await withTimeout(
1656
- engine.merge(),
1769
+ const metas = await withTimeout(
1770
+ engine.listSnapshots(),
1657
1771
  ROUTE_TIMEOUT_MS,
1658
- '同步三方合并超时(5 分钟)',
1772
+ msg('host.syncPullTimeout'),
1659
1773
  )
1660
- // 首次强制预览:sync-state.lastSyncAt === '' 时一律 review
1774
+ const snapshots = [...metas]
1775
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
1776
+ .map((m) => ({
1777
+ id: m.id,
1778
+ createdAt: m.createdAt,
1779
+ sectionCount: m.manifest.sectionIds.length,
1780
+ platform: m.manifest.platform,
1781
+ dshVersion: m.manifest.dshVersion,
1782
+ }))
1661
1783
  const state = await loadSyncState(syncDir)
1662
- const firstSync = state.lastSyncAt === ''
1663
- const apply = classifyMergePlan(merge, { firstSync })
1664
- if (firstSync) {
1665
- state.lastSyncAt = new Date().toISOString()
1666
- await saveSyncState(syncDir, state)
1784
+ writeJson(res, 200, { ok: true, snapshots, currentSnapshotId: state.lastSnapshotId === '' ? undefined : state.lastSnapshotId })
1785
+ } catch (error) {
1786
+ writeSyncRouteError(res, error)
1787
+ }
1788
+ },
1789
+ },
1790
+ // ------------------------------------------------------ sync/sync
1791
+ // m-sync-v2:一键同步第一步 —— 拉取 → 差异确认会话(内存登记临时 ZIP + ImportPlan)。
1792
+ {
1793
+ kind: 'exact',
1794
+ path: API.syncSync,
1795
+ handler: async (req, res) => {
1796
+ if (!guard(req, res, 'POST')) return
1797
+ const body = await readJsonBody(req)
1798
+ if (body === undefined) {
1799
+ writeJson(res, 400, { error: 'invalid JSON body' })
1800
+ return
1801
+ }
1802
+ try {
1803
+ const { repoUrl, gitBin } = await prepareSync(body)
1804
+ const engine = makeSyncEngine(repoUrl, gitBin)
1805
+ const snapshotId = typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined
1806
+ const preview = await withTimeout(
1807
+ engine.preview(snapshotId === undefined ? {} : { snapshotId }),
1808
+ ROUTE_TIMEOUT_MS,
1809
+ msg('host.syncPullTimeout'),
1810
+ )
1811
+ if (!preview.ok || preview.plan === null || preview.analysis === null) {
1812
+ writeJson(res, 200, { ok: false, syncSessionId: '', snapshotId: preview.snapshotId, items: [], needsReview: false, compatibility: 'unsupported', message: preview.message ?? '同步预览失败' })
1813
+ return
1667
1814
  }
1668
- const report = await engine.applyMergePlan(apply)
1669
- writeJson(res, 200, report)
1815
+ const syncSessionId = syncSessions.set({
1816
+ zipPath: preview.zipPath,
1817
+ plan: preview.plan,
1818
+ analysis: preview.analysis,
1819
+ snapshotId: preview.snapshotId,
1820
+ repoUrl,
1821
+ gitBin,
1822
+ })
1823
+ const items = planToConfirmItems(preview.plan)
1824
+ const needsReview = items.some((i) => REVIEW_KINDS.has(i.kind)) || preview.analysis.pathIssues.length > 0
1825
+ writeJson(res, 200, {
1826
+ ok: true,
1827
+ syncSessionId,
1828
+ snapshotId: preview.snapshotId,
1829
+ items,
1830
+ needsReview,
1831
+ compatibility: preview.analysis.compatibility,
1832
+ })
1833
+ } catch (error) {
1834
+ writeSyncRouteError(res, error)
1835
+ }
1836
+ },
1837
+ },
1838
+ // ------------------------------------------------------ sync/apply-items
1839
+ // m-sync-v2:一键同步第二步 —— 按用户对差异项的逐项决策执行导入。
1840
+ {
1841
+ kind: 'exact',
1842
+ path: API.syncApplyItems,
1843
+ handler: async (req, res) => {
1844
+ if (!guard(req, res, 'POST')) return
1845
+ const body = await readJsonBody(req)
1846
+ if (body === undefined) {
1847
+ writeJson(res, 400, { error: 'invalid JSON body' })
1848
+ return
1849
+ }
1850
+ try {
1851
+ const syncSessionId = typeof body['syncSessionId'] === 'string' ? body['syncSessionId'] : ''
1852
+ const session = syncSessions.get(syncSessionId)
1853
+ if (session === undefined) {
1854
+ writeJson(res, 400, { error: '同步会话不存在或已过期,请重新拉取预览' })
1855
+ return
1856
+ }
1857
+ const adoptions = Array.isArray(body['adoptions']) ? body['adoptions'] : []
1858
+ // 构造子计划(仅含采纳项)
1859
+ const byId = new Map<string, { adopt: boolean; resolution?: string }>()
1860
+ for (const a of adoptions as Array<Record<string, unknown>>) {
1861
+ if (typeof a?.['itemId'] !== 'string') continue
1862
+ byId.set(a['itemId'], { adopt: a['adopt'] === true, resolution: typeof a['resolution'] === 'string' ? a['resolution'] : undefined })
1863
+ }
1864
+ const subItems = session.plan.items.filter((item) => {
1865
+ const d = byId.get(item.id)
1866
+ if (d === undefined || !d.adopt) return false
1867
+ // Conflict 项必须有 resolution,且 keepLocal/skip 从子计划剔除
1868
+ if (item.kind === 'Conflict') {
1869
+ if (d.resolution === undefined) throw new SyncRouteError(`冲突项 ${item.id} 必须提供 resolution(useRemote/keepLocal/skip)`)
1870
+ if (d.resolution === 'keepLocal' || d.resolution === 'skip') return false
1871
+ }
1872
+ return true
1873
+ })
1874
+ const subPlan: ImportPlan = {
1875
+ ...session.plan,
1876
+ items: subItems,
1877
+ }
1878
+ // 消费会话(同一 session 只允许一次 apply-items)
1879
+ syncSessions.delete(syncSessionId)
1880
+ await fs.rm(dirname(session.zipPath), { recursive: true, force: true }).catch(() => { /* 尽力清理临时 ZIP */ })
1881
+ const engine = makeSyncEngine(session.repoUrl, session.gitBin)
1882
+ const report = await engine.applyItems(session.zipPath, subPlan, {
1883
+ onItem: (info) => { /* 进度可选:runs 已由 applyItems 内部处理 */ },
1884
+ })
1885
+ writeJson(res, 200, {
1886
+ ok: report.ok,
1887
+ applied: report.applied,
1888
+ skipped: subItems.map((i) => i.id),
1889
+ needsRestart: report.needsRestart === true,
1890
+ warnings: report.warnings,
1891
+ restoreId: report.restoreId,
1892
+ rolledBack: report.rolledBack,
1893
+ failed: report.failed,
1894
+ result: report.result,
1895
+ })
1896
+ } catch (error) {
1897
+ writeSyncRouteError(res, error)
1898
+ }
1899
+ },
1900
+ },
1901
+ // ------------------------------------------------------ sync/cancel
1902
+ // m-sync-v2:取消 / 清理差异确认会话(丢弃临时 ZIP,零副作用)。
1903
+ {
1904
+ kind: 'exact',
1905
+ path: API.syncCancel,
1906
+ handler: async (req, res) => {
1907
+ if (!guard(req, res, 'POST')) return
1908
+ const body = await readJsonBody(req)
1909
+ if (body === undefined) {
1910
+ writeJson(res, 400, { error: 'invalid JSON body' })
1911
+ return
1912
+ }
1913
+ try {
1914
+ const syncSessionId = typeof body['syncSessionId'] === 'string' ? body['syncSessionId'] : ''
1915
+ if (syncSessionId !== '') {
1916
+ const session = syncSessions.get(syncSessionId)
1917
+ if (session !== undefined) {
1918
+ await fs.rm(dirname(session.zipPath), { recursive: true, force: true }).catch(() => { /* 尽力清理临时 ZIP */ })
1919
+ }
1920
+ syncSessions.delete(syncSessionId)
1921
+ }
1922
+ writeJson(res, 200, { ok: true })
1923
+ } catch (error) {
1924
+ writeSyncRouteError(res, error)
1925
+ }
1926
+ },
1927
+ },
1928
+ // ------------------------------------------------------ sync/autosync
1929
+ // m-sync-v2:自动同步配置读写(总开关 + 间隔 + 启动阈值 + 状态)。
1930
+ {
1931
+ kind: 'exact',
1932
+ path: API.syncAutosync,
1933
+ handler: async (req, res) => {
1934
+ if (!guard(req, res, 'GET')) return
1935
+ try {
1936
+ writeJson(res, 200, await buildAutosyncStatus(syncDir))
1937
+ } catch (error) {
1938
+ writeSyncRouteError(res, error)
1939
+ }
1940
+ },
1941
+ },
1942
+ {
1943
+ kind: 'exact',
1944
+ path: API.syncAutosync,
1945
+ handler: async (req, res) => {
1946
+ if (!guard(req, res, 'POST')) return
1947
+ const body = await readJsonBody(req)
1948
+ if (body === undefined) {
1949
+ writeJson(res, 400, { error: 'invalid JSON body' })
1950
+ return
1951
+ }
1952
+ try {
1953
+ const cfg = await readAutosyncConfig(syncDir)
1954
+ if (typeof body['enabled'] === 'boolean') cfg.enabled = body['enabled']
1955
+ if (typeof body['interval'] === 'string' && isAutosyncInterval(body['interval'])) cfg.interval = body['interval']
1956
+ if (typeof body['startupMinIntervalMs'] === 'number' && Number.isFinite(body['startupMinIntervalMs']) && body['startupMinIntervalMs'] > 0) {
1957
+ cfg.startupMinIntervalMs = body['startupMinIntervalMs']
1958
+ }
1959
+ await writeAutosyncConfig(syncDir, cfg)
1960
+ if (scheduler) scheduler.reload().catch(() => { /* 尽力而为 */ })
1961
+ writeJson(res, 200, await buildAutosyncStatus(syncDir))
1670
1962
  } catch (error) {
1671
1963
  writeSyncRouteError(res, error)
1672
1964
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * autosync-config 测试:sync-autosync.json 读写往返、缺省值、损坏 JSON 回退缺省、原子写。
3
+ */
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs/promises';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ import {
11
+ readAutosyncConfig, writeAutosyncConfig, AUTOSYNC_CONFIG_FILE,
12
+ AUTOSYNC_CONFIG_SCHEMA_VERSION, DEFAULT_AUTOSYNC_INTERVAL,
13
+ DEFAULT_STARTUP_MIN_INTERVAL_MS,
14
+ } from './autosync-config.ts';
15
+
16
+ test('writeAutosyncConfig + readAutosyncConfig:写入 → 读回字段一致', async () => {
17
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-rt-'));
18
+ try {
19
+ await writeAutosyncConfig(dir, {
20
+ enabled: true,
21
+ interval: '15m',
22
+ startupMinIntervalMs: 300000,
23
+ consecutiveFailures: 2,
24
+ lastRunAt: '2026-08-16T12:00:00.000Z',
25
+ lastRunStatus: 'skipped',
26
+ lastRunMessage: '有冲突项,跳过',
27
+ lastRunHistoryId: 'hist-1',
28
+ });
29
+ const cfg = await readAutosyncConfig(dir);
30
+ assert.equal(cfg.enabled, true);
31
+ assert.equal(cfg.interval, '15m');
32
+ assert.equal(cfg.startupMinIntervalMs, 300000);
33
+ assert.equal(cfg.consecutiveFailures, 2);
34
+ assert.equal(cfg.lastRunAt, '2026-08-16T12:00:00.000Z');
35
+ assert.equal(cfg.lastRunStatus, 'skipped');
36
+ assert.equal(cfg.lastRunMessage, '有冲突项,跳过');
37
+ assert.equal(cfg.lastRunHistoryId, 'hist-1');
38
+ // 原始文件校验
39
+ const raw = JSON.parse(await fs.readFile(path.join(dir, AUTOSYNC_CONFIG_FILE), 'utf8'));
40
+ assert.equal(raw.schemaVersion, AUTOSYNC_CONFIG_SCHEMA_VERSION);
41
+ assert.equal(raw.enabled, true);
42
+ assert.equal(raw.interval, '15m');
43
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
44
+ });
45
+
46
+ test('readAutosyncConfig:文件不存在 → 返回缺省值', async () => {
47
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-default-'));
48
+ try {
49
+ const cfg = await readAutosyncConfig(dir);
50
+ assert.equal(cfg.enabled, false);
51
+ assert.equal(cfg.interval, DEFAULT_AUTOSYNC_INTERVAL);
52
+ assert.equal(cfg.startupMinIntervalMs, DEFAULT_STARTUP_MIN_INTERVAL_MS);
53
+ assert.equal(cfg.consecutiveFailures, 0);
54
+ assert.equal(cfg.lastRunAt, undefined);
55
+ assert.equal(cfg.lastRunStatus, undefined);
56
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
57
+ });
58
+
59
+ test('readAutosyncConfig:损坏 JSON → 回退缺省', async () => {
60
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-corrupt-'));
61
+ try {
62
+ await fs.writeFile(path.join(dir, AUTOSYNC_CONFIG_FILE), '{not-json', 'utf8');
63
+ const cfg = await readAutosyncConfig(dir);
64
+ assert.equal(cfg.enabled, false);
65
+ assert.equal(cfg.interval, DEFAULT_AUTOSYNC_INTERVAL);
66
+ assert.equal(cfg.startupMinIntervalMs, DEFAULT_STARTUP_MIN_INTERVAL_MS);
67
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
68
+ });
69
+
70
+ test('readAutosyncConfig:不支持的 schemaVersion → 回退缺省', async () => {
71
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-badver-'));
72
+ try {
73
+ await fs.writeFile(path.join(dir, AUTOSYNC_CONFIG_FILE),
74
+ JSON.stringify({ schemaVersion: 99, enabled: true }), 'utf8');
75
+ const cfg = await readAutosyncConfig(dir);
76
+ assert.equal(cfg.enabled, false);
77
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
78
+ });
79
+
80
+ test('writeAutosyncConfig:原子写(自动创建目录)', async () => {
81
+ const base = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-mkdir-'));
82
+ try {
83
+ const dir = path.join(base, 'nested', 'sync');
84
+ await writeAutosyncConfig(dir, { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 });
85
+ const cfg = await readAutosyncConfig(dir);
86
+ assert.ok(cfg);
87
+ assert.equal(cfg.enabled, true);
88
+ // 确认没有残留临时文件
89
+ const files = await fs.readdir(dir);
90
+ assert.ok(files.length >= 1, '应有配置文件');
91
+ assert.ok(!files.some((f) => f.includes('.tmp')), '不应残留 .tmp 临时文件');
92
+ } finally { await fs.rm(base, { recursive: true, force: true }); }
93
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * m-autosync:自动同步配置持久化(sync-autosync.json)。
3
+ *
4
+ * 与 sync-config.json 并列独立文件:语义清楚、schema 演进独立。
5
+ * schemaVersion:1,字段 { enabled, interval, startupMinIntervalMs, consecutiveFailures,
6
+ * lastRunAt, lastRunStatus, lastRunMessage, lastRunHistoryId }。
7
+ *
8
+ * 原子写(临时文件 + rename),损坏/不支持 schema 回退缺省(enabled=false,
9
+ * interval='30m', startupMinIntervalMs=5*60*1000)。
10
+ */
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import crypto from 'node:crypto';
14
+
15
+ import { parseJsonSafe, stringifyJsonSafe } from '../utils/json.ts';
16
+
17
+ export const AUTOSYNC_CONFIG_FILE = 'sync-autosync.json';
18
+ export const AUTOSYNC_CONFIG_SCHEMA_VERSION = 1;
19
+
20
+ /** 统一间隔类型 */
21
+ export type AutosyncInterval = '5m' | '15m' | '30m' | '60m' | '6h' | '12h' | '24h';
22
+
23
+ /** 缺省间隔 */
24
+ export const DEFAULT_AUTOSYNC_INTERVAL: AutosyncInterval = '30m';
25
+
26
+ /** 缺省启动触发最小间隔阈值(5 分钟,防频繁重启反复同步) */
27
+ export const DEFAULT_STARTUP_MIN_INTERVAL_MS = 5 * 60 * 1000;
28
+
29
+ /** 最近一次自动同步执行状态 */
30
+ export type AutosyncRunStatus = 'success' | 'skipped' | 'failed' | 'partial';
31
+
32
+ /** 自动同步配置(持久化面) */
33
+ export interface AutosyncConfig {
34
+ /** 总开关(同时控制上传 + 下载) */
35
+ enabled: boolean;
36
+ /** 统一间隔 */
37
+ interval: AutosyncInterval;
38
+ /** 重启触发的「自动下载合并」最小间隔阈值(ms) */
39
+ startupMinIntervalMs: number;
40
+ /** 连续失败计数(用于通知判定) */
41
+ consecutiveFailures: number;
42
+ /** 最近一次自动同步执行时间(ISO-8601 UTC);''/undefined = 从未执行 */
43
+ lastRunAt?: string;
44
+ /** 最近一次自动同步执行状态 */
45
+ lastRunStatus?: AutosyncRunStatus;
46
+ lastRunMessage?: string;
47
+ /** 最近一次自动同步触发的同步历史条目 id */
48
+ lastRunHistoryId?: string;
49
+ }
50
+
51
+ /** 缺省配置(首次无文件 / 损坏 / 不支持 schema 时回退) */
52
+ export function defaultAutosyncConfig(): AutosyncConfig {
53
+ return {
54
+ enabled: false,
55
+ interval: DEFAULT_AUTOSYNC_INTERVAL,
56
+ startupMinIntervalMs: DEFAULT_STARTUP_MIN_INTERVAL_MS,
57
+ consecutiveFailures: 0,
58
+ };
59
+ }
60
+
61
+ /** 读取自动同步配置;文件不存在 / 损坏 / 不支持 schema → 缺省值(不抛错)。 */
62
+ export async function readAutosyncConfig(dir: string): Promise<AutosyncConfig> {
63
+ const file = path.join(dir, AUTOSYNC_CONFIG_FILE);
64
+ let raw: string;
65
+ try {
66
+ raw = await fs.readFile(file, 'utf8');
67
+ } catch {
68
+ return defaultAutosyncConfig();
69
+ }
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = parseJsonSafe(raw);
73
+ } catch {
74
+ return defaultAutosyncConfig();
75
+ }
76
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
77
+ return defaultAutosyncConfig();
78
+ }
79
+ const obj = parsed as Record<string, unknown>;
80
+ // schemaVersion 必须为 1(不支持其他版本)
81
+ if (obj['schemaVersion'] !== undefined && obj['schemaVersion'] !== AUTOSYNC_CONFIG_SCHEMA_VERSION) {
82
+ return defaultAutosyncConfig();
83
+ }
84
+ if (obj['schemaVersion'] === undefined) {
85
+ return defaultAutosyncConfig();
86
+ }
87
+ const cfg = defaultAutosyncConfig();
88
+ if (typeof obj['enabled'] === 'boolean') cfg.enabled = obj['enabled'];
89
+ if (isAutosyncInterval(obj['interval'])) cfg.interval = obj['interval'];
90
+ if (typeof obj['startupMinIntervalMs'] === 'number' && Number.isFinite(obj['startupMinIntervalMs']) && obj['startupMinIntervalMs'] > 0) {
91
+ cfg.startupMinIntervalMs = obj['startupMinIntervalMs'];
92
+ }
93
+ if (typeof obj['consecutiveFailures'] === 'number' && Number.isFinite(obj['consecutiveFailures']) && obj['consecutiveFailures'] >= 0) {
94
+ cfg.consecutiveFailures = obj['consecutiveFailures'];
95
+ }
96
+ if (typeof obj['lastRunAt'] === 'string' && obj['lastRunAt'] !== '') cfg.lastRunAt = obj['lastRunAt'];
97
+ if (typeof obj['lastRunStatus'] === 'string' && (obj['lastRunStatus'] === 'success' || obj['lastRunStatus'] === 'skipped' || obj['lastRunStatus'] === 'failed' || obj['lastRunStatus'] === 'partial')) {
98
+ cfg.lastRunStatus = obj['lastRunStatus'];
99
+ }
100
+ if (typeof obj['lastRunMessage'] === 'string') cfg.lastRunMessage = obj['lastRunMessage'];
101
+ if (typeof obj['lastRunHistoryId'] === 'string') cfg.lastRunHistoryId = obj['lastRunHistoryId'];
102
+ return cfg;
103
+ }
104
+
105
+ /** 写入自动同步配置(原子写:临时文件 + rename;自动创建目录)。 */
106
+ export async function writeAutosyncConfig(dir: string, cfg: AutosyncConfig): Promise<void> {
107
+ await fs.mkdir(dir, { recursive: true });
108
+ const payload: Record<string, unknown> = {
109
+ schemaVersion: AUTOSYNC_CONFIG_SCHEMA_VERSION,
110
+ enabled: cfg.enabled,
111
+ interval: cfg.interval,
112
+ startupMinIntervalMs: cfg.startupMinIntervalMs,
113
+ consecutiveFailures: cfg.consecutiveFailures,
114
+ };
115
+ if (cfg.lastRunAt !== undefined && cfg.lastRunAt !== '') payload['lastRunAt'] = cfg.lastRunAt;
116
+ if (cfg.lastRunStatus !== undefined) payload['lastRunStatus'] = cfg.lastRunStatus;
117
+ if (cfg.lastRunMessage !== undefined && cfg.lastRunMessage !== '') payload['lastRunMessage'] = cfg.lastRunMessage;
118
+ if (cfg.lastRunHistoryId !== undefined && cfg.lastRunHistoryId !== '') payload['lastRunHistoryId'] = cfg.lastRunHistoryId;
119
+
120
+ const target = path.join(dir, AUTOSYNC_CONFIG_FILE);
121
+ const tmp = path.join(
122
+ dir,
123
+ `.${AUTOSYNC_CONFIG_FILE}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`,
124
+ );
125
+ const data = stringifyJsonSafe(payload, { space: 2 });
126
+ try {
127
+ await fs.writeFile(tmp, data, 'utf8');
128
+ await fs.rename(tmp, target);
129
+ } catch (err) {
130
+ try { await fs.rm(tmp, { force: true }); } catch { /* ignore */ }
131
+ throw err;
132
+ }
133
+ }
134
+
135
+ function isAutosyncInterval(v: unknown): v is AutosyncInterval {
136
+ return v === '5m' || v === '15m' || v === '30m' || v === '60m' || v === '6h' || v === '12h' || v === '24h';
137
+ }