dsh-config-manager 0.1.19 → 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.
- package/lib/client.d.ts +319 -2
- package/lib/client.js +1271 -109
- package/lib/client.js.map +1 -1
- package/lib/core/run-registry.d.ts +2 -2
- package/lib/core/run-registry.js +5 -1
- package/lib/core/run-registry.js.map +1 -1
- package/lib/index.js +408 -2
- package/lib/index.js.map +1 -1
- package/lib/sync/ancestor.d.ts +17 -0
- package/lib/sync/ancestor.js +64 -0
- package/lib/sync/ancestor.js.map +1 -0
- package/lib/sync/autosync-config.d.ts +34 -0
- package/lib/sync/autosync-config.js +116 -0
- package/lib/sync/autosync-config.js.map +1 -0
- package/lib/sync/autosync-scheduler.d.ts +95 -0
- package/lib/sync/autosync-scheduler.js +379 -0
- package/lib/sync/autosync-scheduler.js.map +1 -0
- package/lib/sync/merge.d.ts +42 -0
- package/lib/sync/merge.js +325 -0
- package/lib/sync/merge.js.map +1 -0
- package/lib/sync/review-queue.d.ts +51 -0
- package/lib/sync/review-queue.js +119 -0
- package/lib/sync/review-queue.js.map +1 -0
- package/lib/sync/risk.d.ts +59 -0
- package/lib/sync/risk.js +76 -0
- package/lib/sync/risk.js.map +1 -0
- package/lib/sync/sync-config.d.ts +7 -2
- package/lib/sync/sync-config.js +24 -4
- package/lib/sync/sync-config.js.map +1 -1
- package/lib/sync/sync-engine.d.ts +88 -2
- package/lib/sync/sync-engine.js +333 -8
- package/lib/sync/sync-engine.js.map +1 -1
- package/lib/sync/sync-history.d.ts +39 -0
- package/lib/sync/sync-history.js +88 -0
- package/lib/sync/sync-history.js.map +1 -0
- package/lib/sync/sync-session.d.ts +39 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-session.js.map +1 -0
- package/lib/sync/sync-state.d.ts +7 -2
- package/lib/sync/sync-state.js +9 -5
- package/lib/sync/sync-state.js.map +1 -1
- package/lib/ui/i18n.d.ts +63 -0
- package/lib/ui/i18n.js +130 -1
- package/lib/ui/i18n.js.map +1 -1
- package/package.json +1 -1
- package/src/client/config-manager.module.css +18 -0
- package/src/client/sync/SyncConfirmView.tsx +301 -0
- package/src/client/sync/SyncHistoryView.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +155 -0
- package/src/client/sync/SyncSettingsView.tsx +241 -12
- package/src/client/sync/history-model.test.ts +82 -0
- package/src/client/sync/history-model.ts +112 -0
- package/src/client/sync/sync-api.test.ts +155 -0
- package/src/client/sync/sync-api.ts +219 -0
- package/src/client/sync/sync-locales.ts +162 -1
- package/src/client/sync/sync-view-v2.test.ts +131 -0
- package/src/client/sync/sync-view.ts +151 -4
- package/src/core/run-registry.ts +7 -3
- package/src/index.ts +420 -3
- package/src/sync/ancestor.test.ts +152 -0
- package/src/sync/ancestor.ts +81 -0
- package/src/sync/autosync-config.test.ts +93 -0
- package/src/sync/autosync-config.ts +137 -0
- package/src/sync/autosync-scheduler.test.ts +191 -0
- package/src/sync/autosync-scheduler.ts +443 -0
- package/src/sync/merge.test.ts +204 -0
- package/src/sync/merge.ts +360 -0
- package/src/sync/review-queue.test.ts +120 -0
- package/src/sync/review-queue.ts +167 -0
- package/src/sync/risk.test.ts +131 -0
- package/src/sync/risk.ts +122 -0
- package/src/sync/sync-config.test.ts +106 -0
- package/src/sync/sync-config.ts +23 -4
- package/src/sync/sync-engine.test.ts +392 -3
- package/src/sync/sync-engine.ts +383 -10
- package/src/sync/sync-history.test.ts +85 -0
- package/src/sync/sync-history.ts +126 -0
- package/src/sync/sync-session.test.ts +137 -0
- package/src/sync/sync-session.ts +76 -0
- package/src/sync/sync-state.test.ts +48 -2
- package/src/sync/sync-state.ts +11 -5
- package/src/ui/i18n.ts +132 -3
package/src/index.ts
CHANGED
|
@@ -58,6 +58,7 @@ import * as yaml from 'js-yaml'
|
|
|
58
58
|
|
|
59
59
|
import { Exporter, FileSnapshotStore, Importer } from './core/index.ts'
|
|
60
60
|
import { listSnapshots, planRestore, type RestorePlan, type RestoreReport } from './core/restore.ts'
|
|
61
|
+
import { rollback as performRollback } from './core/rollback.ts'
|
|
61
62
|
import { RunRegistry, type RunState } from './core/run-registry.ts'
|
|
62
63
|
import { makeMsg, msgOf, zhMsg } from './core/messages.ts'
|
|
63
64
|
import type { MsgFunc } from './core/messages.ts'
|
|
@@ -67,7 +68,7 @@ import {
|
|
|
67
68
|
} from './core/plugin-cli.ts'
|
|
68
69
|
import type {
|
|
69
70
|
ConfigAdapter, CredentialsFacade, FileSystemFacade, HostContext, ImportDecisions,
|
|
70
|
-
ImportPlan, NamespaceInfo, PatchFileFacade, PluginInfo, PluginsFacade,
|
|
71
|
+
ImportPlan, NamespaceInfo, PatchFileFacade, PlanItemKind, PluginInfo, PluginsFacade,
|
|
71
72
|
SettingsFacade, WorkspaceFacade,
|
|
72
73
|
} from './core/types.ts'
|
|
73
74
|
import { createAdapters, USER_PATCH_FILE } from './adapters/index.ts'
|
|
@@ -76,7 +77,12 @@ import { createHardenedZipParser } from './security/zip-security.ts'
|
|
|
76
77
|
import { GitTransport } from './sync/git/git-transport.ts'
|
|
77
78
|
import { DeviceFlowStore, GitHubAuthClient } from './sync/github-auth.ts'
|
|
78
79
|
import { SyncEngine } from './sync/sync-engine.ts'
|
|
79
|
-
import {
|
|
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'
|
|
85
|
+
import { loadSyncState, saveSyncState } from './sync/sync-state.ts'
|
|
80
86
|
import { readSyncConfig, writeSyncConfig, validateRepoUrl } from './sync/sync-config.ts'
|
|
81
87
|
import { MANIFEST_FILE, parseManifest } from './schema/manifest.ts'
|
|
82
88
|
import { SECTION_IDS } from './schema/config.ts'
|
|
@@ -94,7 +100,7 @@ export const name = 'config-manager'
|
|
|
94
100
|
export const inject = ['settings', 'credentials']
|
|
95
101
|
|
|
96
102
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
97
|
-
const PLUGIN_VERSION = '0.1.
|
|
103
|
+
const PLUGIN_VERSION = '0.1.20'
|
|
98
104
|
|
|
99
105
|
/**
|
|
100
106
|
* 内置 GitHub OAuth App 的 client_id(「使用 GitHub 登录」device flow 缺省值)。
|
|
@@ -148,6 +154,15 @@ const API = {
|
|
|
148
154
|
syncGithubStart: '/api/dsh-config-manager/sync/github/start',
|
|
149
155
|
syncGithubPoll: '/api/dsh-config-manager/sync/github/poll',
|
|
150
156
|
syncGithubCancel: '/api/dsh-config-manager/sync/github/cancel',
|
|
157
|
+
// P2:同步历史 / 自动应用 / 一键回滚
|
|
158
|
+
syncHistory: '/api/dsh-config-manager/sync/history',
|
|
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',
|
|
151
166
|
} as const
|
|
152
167
|
|
|
153
168
|
/**
|
|
@@ -822,6 +837,88 @@ export function writeSyncRouteError(res: ServerResponse, error: unknown): void {
|
|
|
822
837
|
writeJson(res, 500, { error: message })
|
|
823
838
|
}
|
|
824
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
|
+
|
|
825
922
|
/* -------------------------------------------------- restore 路由(M4) */
|
|
826
923
|
|
|
827
924
|
/** POST /restore 请求体校验(纯函数;snapshotId 拒绝路径分隔符防 join 越界)。 */
|
|
@@ -1043,6 +1140,21 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
|
|
|
1043
1140
|
});
|
|
1044
1141
|
}
|
|
1045
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
|
+
|
|
1046
1158
|
return [
|
|
1047
1159
|
// ------------------------------------------------------------- status
|
|
1048
1160
|
{
|
|
@@ -1401,6 +1513,7 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
|
|
|
1401
1513
|
lastSyncAt: state.lastSyncAt === '' ? undefined : state.lastSyncAt,
|
|
1402
1514
|
sectionCount: Object.keys(state.sections).length,
|
|
1403
1515
|
transport: state.transport,
|
|
1516
|
+
autosync: await buildAutosyncStatus(syncDir),
|
|
1404
1517
|
})
|
|
1405
1518
|
} catch (error) {
|
|
1406
1519
|
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
@@ -1574,6 +1687,310 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
|
|
|
1574
1687
|
writeJson(res, 200, { ok: true })
|
|
1575
1688
|
},
|
|
1576
1689
|
},
|
|
1690
|
+
// ------------------------------------------------------ sync/history
|
|
1691
|
+
// P2:列出本地祖先快照目录的 manifest.json(id/createdAt/sectionHashes),
|
|
1692
|
+
// 同时统计 review-queue 中关联到该 snapshotId 的项数。
|
|
1693
|
+
{
|
|
1694
|
+
kind: 'exact',
|
|
1695
|
+
path: API.syncHistory,
|
|
1696
|
+
handler: async (req, res) => {
|
|
1697
|
+
if (!guard(req, res, 'GET')) return
|
|
1698
|
+
try {
|
|
1699
|
+
const localDir = join(syncDir, 'snapshots')
|
|
1700
|
+
const entries = await fs.readdir(localDir).catch(() => [])
|
|
1701
|
+
const rows: Array<{ id: string; createdAt: string; sectionCount: number; reviewCount: number }> = []
|
|
1702
|
+
for (const name of entries) {
|
|
1703
|
+
const dir = join(localDir, name)
|
|
1704
|
+
const stat = await fs.stat(dir).catch(() => null)
|
|
1705
|
+
if (!stat?.isDirectory()) continue
|
|
1706
|
+
const manifestPath = join(dir, 'manifest.json')
|
|
1707
|
+
const raw = await fs.readFile(manifestPath, 'utf8').catch(() => null)
|
|
1708
|
+
if (raw === null) continue
|
|
1709
|
+
try {
|
|
1710
|
+
const m = JSON.parse(raw) as { id?: unknown; createdAt?: unknown; sectionHashes?: unknown }
|
|
1711
|
+
if (typeof m.id !== 'string' || typeof m.createdAt !== 'string') continue
|
|
1712
|
+
const sectionCount = m.sectionHashes && typeof m.sectionHashes === 'object'
|
|
1713
|
+
? Object.keys(m.sectionHashes as Record<string, unknown>).length
|
|
1714
|
+
: 0
|
|
1715
|
+
rows.push({ id: m.id, createdAt: m.createdAt, sectionCount, reviewCount: 0 })
|
|
1716
|
+
} catch { /* skip malformed */ }
|
|
1717
|
+
}
|
|
1718
|
+
// 关联 review-queue 计数
|
|
1719
|
+
const rqPath = join(syncDir, 'sync-review-queue.json')
|
|
1720
|
+
const rqRaw = await fs.readFile(rqPath, 'utf8').catch(() => null)
|
|
1721
|
+
if (rqRaw !== null) {
|
|
1722
|
+
try {
|
|
1723
|
+
const rq = JSON.parse(rqRaw) as { items?: Array<{ snapshotId?: string }> }
|
|
1724
|
+
const byId = new Map<string, number>()
|
|
1725
|
+
for (const it of rq.items ?? []) {
|
|
1726
|
+
if (typeof it.snapshotId === 'string') {
|
|
1727
|
+
byId.set(it.snapshotId, (byId.get(it.snapshotId) ?? 0) + 1)
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
for (const r of rows) {
|
|
1731
|
+
const c = byId.get(r.id)
|
|
1732
|
+
if (c !== undefined) r.reviewCount = c
|
|
1733
|
+
}
|
|
1734
|
+
} catch { /* skip */ }
|
|
1735
|
+
}
|
|
1736
|
+
rows.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
|
|
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 })
|
|
1749
|
+
} catch (error) {
|
|
1750
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
1751
|
+
}
|
|
1752
|
+
},
|
|
1753
|
+
},
|
|
1754
|
+
// ------------------------------------------------------ sync/snapshots-list
|
|
1755
|
+
// m-sync-v2:远端历史快照列表(供「选择历史快照」下拉)。
|
|
1756
|
+
{
|
|
1757
|
+
kind: 'exact',
|
|
1758
|
+
path: API.syncSnapshotsList,
|
|
1759
|
+
handler: async (req, res) => {
|
|
1760
|
+
if (!guard(req, res, 'POST')) return
|
|
1761
|
+
const body = await readJsonBody(req)
|
|
1762
|
+
if (body === undefined) {
|
|
1763
|
+
writeJson(res, 400, { error: 'invalid JSON body' })
|
|
1764
|
+
return
|
|
1765
|
+
}
|
|
1766
|
+
try {
|
|
1767
|
+
const { repoUrl, gitBin } = await prepareSync(body)
|
|
1768
|
+
const engine = makeSyncEngine(repoUrl, gitBin)
|
|
1769
|
+
const metas = await withTimeout(
|
|
1770
|
+
engine.listSnapshots(),
|
|
1771
|
+
ROUTE_TIMEOUT_MS,
|
|
1772
|
+
msg('host.syncPullTimeout'),
|
|
1773
|
+
)
|
|
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
|
+
}))
|
|
1783
|
+
const state = await loadSyncState(syncDir)
|
|
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
|
|
1814
|
+
}
|
|
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))
|
|
1962
|
+
} catch (error) {
|
|
1963
|
+
writeSyncRouteError(res, error)
|
|
1964
|
+
}
|
|
1965
|
+
},
|
|
1966
|
+
},
|
|
1967
|
+
// ------------------------------------------------------ sync/rollback
|
|
1968
|
+
// P2:UI 一键回滚入口(按 apply 返回的 restoreId 调用 backup→rollback)。
|
|
1969
|
+
{
|
|
1970
|
+
kind: 'exact',
|
|
1971
|
+
path: API.syncRollback,
|
|
1972
|
+
handler: async (req, res) => {
|
|
1973
|
+
if (!guard(req, res, 'POST')) return
|
|
1974
|
+
const body = await readJsonBody(req)
|
|
1975
|
+
if (body === undefined) {
|
|
1976
|
+
writeJson(res, 400, { error: 'invalid JSON body' })
|
|
1977
|
+
return
|
|
1978
|
+
}
|
|
1979
|
+
try {
|
|
1980
|
+
const restoreId = typeof body['restoreId'] === 'string' ? body['restoreId'] : ''
|
|
1981
|
+
if (restoreId === '') {
|
|
1982
|
+
writeJson(res, 400, { error: 'restoreId required' })
|
|
1983
|
+
return
|
|
1984
|
+
}
|
|
1985
|
+
const store = new FileSnapshotStore({ dir: join(syncDir, 'snapshots') })
|
|
1986
|
+
const snap = await store.load(restoreId)
|
|
1987
|
+
const report = await performRollback({ ctx: host, snapshot: snap, store, adapters })
|
|
1988
|
+
writeJson(res, 200, { ok: true, full: report.full })
|
|
1989
|
+
} catch (error) {
|
|
1990
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
1991
|
+
}
|
|
1992
|
+
},
|
|
1993
|
+
},
|
|
1577
1994
|
]
|
|
1578
1995
|
}
|
|
1579
1996
|
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-flow:ancestor 存储助手测试。
|
|
3
|
+
* - loadAncestor: 存在返回 SyncSnapshot;不存在抛错
|
|
4
|
+
* - writeAncestor: 写出目录能被 readSnapshotFromDir 完整读回,分区 hash 一致
|
|
5
|
+
* - pruneAncestors: 仅删超出 keep 的最旧副本;保留集合完整无损
|
|
6
|
+
*/
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { loadAncestor, pruneAncestors, writeAncestor, DEFAULT_ANCESTOR_KEEP } from './ancestor.ts';
|
|
14
|
+
import { hashSection, loadSyncState, saveSyncState } from './sync-state.ts';
|
|
15
|
+
import type { SectionData, FilesSection } from '../schema/types.ts';
|
|
16
|
+
import type { SyncSnapshot } from './transport.ts';
|
|
17
|
+
|
|
18
|
+
function mkSnapshot(id: string, createdAt: string, sections: SyncSnapshot['sections']): SyncSnapshot {
|
|
19
|
+
return {
|
|
20
|
+
id,
|
|
21
|
+
createdAt,
|
|
22
|
+
manifest: {
|
|
23
|
+
schemaVersion: 1,
|
|
24
|
+
dshVersion: 'test',
|
|
25
|
+
platform: 'linux',
|
|
26
|
+
sectionIds: Object.keys(sections) as SyncSnapshot['manifest']['sectionIds'],
|
|
27
|
+
containsSecrets: false,
|
|
28
|
+
},
|
|
29
|
+
sections,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('loadAncestor: 存在 → 返回 SyncSnapshot(与 writeAncestor 写入一致)', async () => {
|
|
34
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-ancestor-load-'));
|
|
35
|
+
try {
|
|
36
|
+
const snap = mkSnapshot('sync-1', '2026-08-16T10:00:00.000Z', {
|
|
37
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } } as SectionData,
|
|
38
|
+
});
|
|
39
|
+
await writeAncestor(dir, snap);
|
|
40
|
+
const loaded = await loadAncestor(dir, 'sync-1');
|
|
41
|
+
assert.equal(loaded.id, 'sync-1');
|
|
42
|
+
assert.equal(loaded.createdAt, '2026-08-16T10:00:00.000Z');
|
|
43
|
+
assert.equal(loaded.sections.settings && hashSection(loaded.sections.settings as SectionData), hashSection(snap.sections.settings as SectionData));
|
|
44
|
+
} finally {
|
|
45
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('loadAncestor: 不存在 → 抛错(不静默降级)', async () => {
|
|
50
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-ancestor-missing-'));
|
|
51
|
+
try {
|
|
52
|
+
await assert.rejects(() => loadAncestor(dir, 'sync-nonexistent'));
|
|
53
|
+
await assert.rejects(() => loadAncestor(dir, ''), /snapshotId/);
|
|
54
|
+
} finally {
|
|
55
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('writeAncestor: 写出目录可被 readSnapshotFromDir 读回,分区 hash 一致', async () => {
|
|
60
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-ancestor-write-'));
|
|
61
|
+
try {
|
|
62
|
+
const files: FilesSection = {
|
|
63
|
+
version: 1,
|
|
64
|
+
files: [
|
|
65
|
+
{ relativePath: 'a.md', data: new TextEncoder().encode('# A\n'), contentHash: '' },
|
|
66
|
+
{ relativePath: 'b/skill.md', data: new TextEncoder().encode('# B\n'), contentHash: '' },
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
const snap = mkSnapshot('sync-2', '2026-08-16T11:00:00.000Z', { skills: files as unknown as SectionData });
|
|
70
|
+
await writeAncestor(dir, snap);
|
|
71
|
+
const loaded = await loadAncestor(dir, 'sync-2');
|
|
72
|
+
assert.equal(hashSection(loaded.sections.skills as SectionData), hashSection(files as unknown as SectionData));
|
|
73
|
+
assert.equal((loaded.sections.skills as FilesSection).files.length, 2);
|
|
74
|
+
} finally {
|
|
75
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('pruneAncestors: 仅删超出 keep 的最旧副本;保留集合完整无损', async () => {
|
|
80
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-ancestor-prune-'));
|
|
81
|
+
try {
|
|
82
|
+
for (let i = 0; i < 12; i++) {
|
|
83
|
+
const t = `2026-08-16T10:0${i % 10}:00.000Z`.replace('10:00', `10:${String(i).padStart(2, '0')}`);
|
|
84
|
+
const iso = i < 10
|
|
85
|
+
? `2026-08-16T10:0${i}:00.000Z`
|
|
86
|
+
: `2026-08-16T10:${i}:00.000Z`;
|
|
87
|
+
const snap = mkSnapshot(`sync-${String(i).padStart(2, '0')}`, iso, {
|
|
88
|
+
settings: { version: 1, namespaces: {} } as SectionData,
|
|
89
|
+
});
|
|
90
|
+
await writeAncestor(dir, snap);
|
|
91
|
+
}
|
|
92
|
+
const removed = await pruneAncestors(dir, 10);
|
|
93
|
+
assert.equal(removed.length, 2, '12 - 10 = 2 个最旧被删');
|
|
94
|
+
assert.equal(removed[0], 'sync-00');
|
|
95
|
+
assert.equal(removed[1], 'sync-01');
|
|
96
|
+
// 剩余 10 个仍在
|
|
97
|
+
for (let i = 2; i < 12; i++) {
|
|
98
|
+
const loaded = await loadAncestor(dir, `sync-${String(i).padStart(2, '0')}`);
|
|
99
|
+
assert.equal(loaded.id, `sync-${String(i).padStart(2, '0')}`);
|
|
100
|
+
}
|
|
101
|
+
await assert.rejects(() => loadAncestor(dir, 'sync-00'));
|
|
102
|
+
await assert.rejects(() => loadAncestor(dir, 'sync-01'));
|
|
103
|
+
} finally {
|
|
104
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('pruneAncestors: keep<=0 视为保留全部', async () => {
|
|
109
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-ancestor-prune0-'));
|
|
110
|
+
try {
|
|
111
|
+
for (let i = 0; i < 3; i++) {
|
|
112
|
+
const snap = mkSnapshot(`s-${i}`, `2026-08-16T10:0${i}:00.000Z`, {
|
|
113
|
+
settings: { version: 1, namespaces: {} } as SectionData,
|
|
114
|
+
});
|
|
115
|
+
await writeAncestor(dir, snap);
|
|
116
|
+
}
|
|
117
|
+
const removed = await pruneAncestors(dir, 0);
|
|
118
|
+
assert.equal(removed.length, 0);
|
|
119
|
+
for (let i = 0; i < 3; i++) {
|
|
120
|
+
const loaded = await loadAncestor(dir, `s-${i}`);
|
|
121
|
+
assert.equal(loaded.id, `s-${i}`);
|
|
122
|
+
}
|
|
123
|
+
} finally {
|
|
124
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('DEFAULT_ANCESTOR_KEEP 默认值符合规划(10)', () => {
|
|
129
|
+
assert.equal(DEFAULT_ANCESTOR_KEEP, 10);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// 同时验证 sync-state 在 v2 下 lastSnapshotId 可被 save/load 完整往返(与 M1 互补)
|
|
133
|
+
test('saveSyncState + loadSyncState: 写入 v2 含 lastSnapshotId 往返完整(与 M1 互补验证)', async () => {
|
|
134
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-ancestor-rt-'));
|
|
135
|
+
try {
|
|
136
|
+
await saveSyncState(tmp, {
|
|
137
|
+
schemaVersion: 2,
|
|
138
|
+
lastSyncAt: '2026-08-16T12:00:00.000Z',
|
|
139
|
+
sections: {},
|
|
140
|
+
lastSnapshotId: 'sync-1',
|
|
141
|
+
});
|
|
142
|
+
const s = await loadSyncState(tmp);
|
|
143
|
+
assert.equal(s.lastSnapshotId, 'sync-1');
|
|
144
|
+
} finally {
|
|
145
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// 抑制 unused import 警告(hashSection/saveSyncState 通过 M1 测试已用到,此处仅 import 即可)
|
|
150
|
+
void hashSection;
|
|
151
|
+
void saveSyncState;
|
|
152
|
+
void path;
|