dsh-config-manager 0.1.12 → 0.1.13
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/README.md +30 -0
- package/README.zh-CN.md +30 -0
- package/lib/adapters/test-helpers.d.ts +4 -2
- package/lib/adapters/test-helpers.js +11 -3
- package/lib/adapters/test-helpers.js.map +1 -1
- package/lib/cli/index.d.ts +44 -0
- package/lib/cli/index.js +230 -0
- package/lib/cli/index.js.map +1 -0
- package/lib/client.d.ts +260 -5
- package/lib/client.js +2519 -820
- package/lib/client.js.map +1 -1
- package/lib/core/analyzer.d.ts +18 -1
- package/lib/core/analyzer.js +29 -0
- package/lib/core/analyzer.js.map +1 -1
- package/lib/core/backup.d.ts +3 -1
- package/lib/core/backup.js +70 -0
- package/lib/core/backup.js.map +1 -1
- package/lib/core/exporter.d.ts +11 -0
- package/lib/core/exporter.js +11 -0
- package/lib/core/exporter.js.map +1 -1
- package/lib/core/importer.d.ts +3 -0
- package/lib/core/importer.js +1 -0
- package/lib/core/importer.js.map +1 -1
- package/lib/core/index.d.ts +1 -0
- package/lib/core/index.js +1 -0
- package/lib/core/index.js.map +1 -1
- package/lib/core/restore.d.ts +92 -0
- package/lib/core/restore.js +505 -0
- package/lib/core/restore.js.map +1 -0
- package/lib/core/run-registry.d.ts +65 -0
- package/lib/core/run-registry.js +120 -0
- package/lib/core/run-registry.js.map +1 -0
- package/lib/core/types.d.ts +20 -0
- package/lib/core/types.js.map +1 -1
- package/lib/index.d.ts +45 -0
- package/lib/index.js +442 -8
- package/lib/index.js.map +1 -1
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +47 -0
- package/lib/sync/fs.js.map +1 -0
- package/lib/sync/git/git-transport.d.ts +87 -0
- package/lib/sync/git/git-transport.js +333 -0
- package/lib/sync/git/git-transport.js.map +1 -0
- package/lib/sync/layout.d.ts +29 -0
- package/lib/sync/layout.js +166 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/sync-config.d.ts +16 -0
- package/lib/sync/sync-config.js +67 -0
- package/lib/sync/sync-config.js.map +1 -0
- package/lib/sync/sync-engine.d.ts +96 -0
- package/lib/sync/sync-engine.js +236 -0
- package/lib/sync/sync-engine.js.map +1 -0
- package/lib/sync/sync-state.d.ts +31 -0
- package/lib/sync/sync-state.js +103 -0
- package/lib/sync/sync-state.js.map +1 -0
- package/lib/sync/transport.d.ts +50 -0
- package/lib/sync/transport.js +22 -0
- package/lib/sync/transport.js.map +1 -0
- package/package.json +4 -1
- package/src/adapters/test-helpers.ts +10 -3
- package/src/cli/index.ts +278 -0
- package/src/client/ConfigManagerSection.tsx +49 -14
- package/src/client/api.ts +53 -5
- package/src/client/client-types.ts +0 -6
- package/src/client/common/ProgressBar.tsx +31 -19
- package/src/client/common/progress-view-edge.test.ts +60 -0
- package/src/client/common/progress-view.test.ts +79 -0
- package/src/client/common/progress-view.ts +94 -0
- package/src/client/config-manager.module.css +99 -20
- package/src/client/export/ExportView.tsx +63 -49
- package/src/client/import/ImportWizardView.tsx +107 -66
- package/src/client/index.ts +22 -0
- package/src/client/locales.ts +53 -0
- package/src/client/run-store.test.ts +500 -0
- package/src/client/run-store.ts +772 -0
- package/src/client/snapshots/SnapshotsPanel.tsx +240 -0
- package/src/client/sync/SyncSettingsView.tsx +261 -0
- package/src/client/sync/sync-api.test.ts +117 -0
- package/src/client/sync/sync-api.ts +132 -0
- package/src/client/sync/sync-locales.ts +77 -0
- package/src/client/sync/sync-view.test.ts +211 -0
- package/src/client/sync/sync-view.ts +202 -0
- package/src/core/analyzer.ts +50 -1
- package/src/core/backup.ts +78 -2
- package/src/core/exporter.ts +21 -0
- package/src/core/importer.ts +4 -0
- package/src/core/index.ts +5 -0
- package/src/core/restore.ts +587 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +139 -0
- package/src/core/run-registry.ts +166 -0
- package/src/core/smoke.test.ts +5 -0
- package/src/core/types.ts +22 -0
- package/src/index.ts +476 -8
- package/src/security/security.test.ts +5 -0
- package/src/sync/fs.ts +46 -0
- package/src/sync/git/git-transport.test.ts +456 -0
- package/src/sync/git/git-transport.ts +390 -0
- package/src/sync/layout.test.ts +226 -0
- package/src/sync/layout.ts +183 -0
- package/src/sync/sync-config.ts +72 -0
- package/src/sync/sync-engine.test.ts +299 -0
- package/src/sync/sync-engine.ts +318 -0
- package/src/sync/sync-state.test.ts +118 -0
- package/src/sync/sync-state.ts +123 -0
- package/src/sync/transport.test.ts +110 -0
- package/src/sync/transport.ts +73 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 快照恢复面板(M4):列出快照 → 选择 → dry-run 计划预览 → 确认执行 → 诚实报告。
|
|
3
|
+
*
|
|
4
|
+
* 数据流:api.snapshots() 加载列表;选择快照后 api.restoreSnapshot(id, true) 拿
|
|
5
|
+
* 恢复计划(零写入预览);确认后 api.restoreSnapshot(id, false) 执行并展示报告
|
|
6
|
+
* (restored / removedPlugins / manualHints(人工项高亮)/ failed / skipped)。
|
|
7
|
+
* 全部状态组件内自持:恢复是低频显式操作,不进入 sessionStorage(快照列表可随时重载)。
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import type { ReactNode } from 'react'
|
|
11
|
+
import type { RestorePlan, RestoreReport, SnapshotMeta } from '../../core/restore.ts'
|
|
12
|
+
import type { ConfigManagerApi } from '../api.ts'
|
|
13
|
+
import type { TranslateNS } from '../client-types.ts'
|
|
14
|
+
import { Badge, Banner, Button, Card, Empty, SectionTitle, Spinner } from '../common/ui.tsx'
|
|
15
|
+
import css from '../config-manager.module.css'
|
|
16
|
+
|
|
17
|
+
export interface SnapshotsPanelProps {
|
|
18
|
+
api: ConfigManagerApi
|
|
19
|
+
t: TranslateNS<'config-manager'>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PanelState {
|
|
23
|
+
status: 'loading' | 'ready' | 'error'
|
|
24
|
+
error: string | null
|
|
25
|
+
metas: SnapshotMeta[]
|
|
26
|
+
selectedId: string | null
|
|
27
|
+
planning: boolean
|
|
28
|
+
plan: RestorePlan | null
|
|
29
|
+
running: boolean
|
|
30
|
+
report: RestoreReport | null
|
|
31
|
+
actionError: string | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const initial: PanelState = {
|
|
35
|
+
status: 'loading',
|
|
36
|
+
error: null,
|
|
37
|
+
metas: [],
|
|
38
|
+
selectedId: null,
|
|
39
|
+
planning: false,
|
|
40
|
+
plan: null,
|
|
41
|
+
running: false,
|
|
42
|
+
report: null,
|
|
43
|
+
actionError: null,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function statusLabel(t: TranslateNS<'config-manager'>, status: SnapshotMeta['status']): string {
|
|
47
|
+
switch (status) {
|
|
48
|
+
case 'pending': return t('snapshots.status.pending')
|
|
49
|
+
case 'done': return t('snapshots.status.done')
|
|
50
|
+
case 'rolled-back': return t('snapshots.status.rolled-back')
|
|
51
|
+
default: return t('snapshots.status.unknown')
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function statusBadgeKind(status: SnapshotMeta['status']): 'info' | 'ok' | 'warn' | 'error' {
|
|
56
|
+
switch (status) {
|
|
57
|
+
case 'pending': return 'info'
|
|
58
|
+
case 'done': return 'ok'
|
|
59
|
+
case 'rolled-back': return 'warn'
|
|
60
|
+
default: return 'error'
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 计划动作的本地化描述前缀(kind 标签 + 引擎已生成的中文 description) */
|
|
65
|
+
function actionKindLabel(kind: string): string {
|
|
66
|
+
switch (kind) {
|
|
67
|
+
case 'hostFileRestore': return '整文件还原'
|
|
68
|
+
case 'hostFileRemove': return '整文件删除'
|
|
69
|
+
case 'pluginRemove': return '卸载插件'
|
|
70
|
+
case 'fileRestore': return '还原文件'
|
|
71
|
+
case 'fileRemove': return '删除文件'
|
|
72
|
+
case 'credentialHint': return '人工提示'
|
|
73
|
+
case 'skip': return '跳过'
|
|
74
|
+
default: return kind
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function SnapshotsPanel({ api, t }: SnapshotsPanelProps) {
|
|
79
|
+
const [state, setState] = useState<PanelState>(initial)
|
|
80
|
+
|
|
81
|
+
const load = (): void => {
|
|
82
|
+
setState((s) => ({ ...s, status: 'loading', error: null }))
|
|
83
|
+
api.snapshots().then(
|
|
84
|
+
(metas) => { setState((s) => ({ ...s, status: 'ready', metas })) },
|
|
85
|
+
(err) => {
|
|
86
|
+
setState((s) => ({
|
|
87
|
+
...s,
|
|
88
|
+
status: 'error',
|
|
89
|
+
error: err instanceof Error ? err.message : String(err),
|
|
90
|
+
}))
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
useEffect(load, [api])
|
|
96
|
+
|
|
97
|
+
const select = (id: string): void => {
|
|
98
|
+
setState((s) => ({ ...s, selectedId: id, plan: null, report: null, actionError: null, planning: true }))
|
|
99
|
+
api.restoreSnapshot(id, true).then(
|
|
100
|
+
(res) => { setState((s) => ({ ...s, planning: false, plan: res.plan ?? null })) },
|
|
101
|
+
(err) => {
|
|
102
|
+
setState((s) => ({
|
|
103
|
+
...s,
|
|
104
|
+
planning: false,
|
|
105
|
+
actionError: err instanceof Error ? err.message : String(err),
|
|
106
|
+
}))
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const execute = (): void => {
|
|
112
|
+
if (state.selectedId === null || state.running) return
|
|
113
|
+
setState((s) => ({ ...s, running: true, report: null, actionError: null }))
|
|
114
|
+
api.restoreSnapshot(state.selectedId, false).then(
|
|
115
|
+
(res) => { setState((s) => ({ ...s, running: false, report: res.report ?? null })) },
|
|
116
|
+
(err) => {
|
|
117
|
+
setState((s) => ({
|
|
118
|
+
...s,
|
|
119
|
+
running: false,
|
|
120
|
+
actionError: err instanceof Error ? err.message : String(err),
|
|
121
|
+
}))
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const summary = (): string => {
|
|
127
|
+
const s = state.plan?.summary
|
|
128
|
+
if (s === undefined) return ''
|
|
129
|
+
return `整文件还原 ${s.hostFileRestores} · 整文件删除 ${s.hostFileRemoves} · 插件卸载 ${s.pluginRemoves}`
|
|
130
|
+
+ ` · 文件还原 ${s.fileRestores} · 文件删除 ${s.fileRemoves} · 凭据提示 ${s.credentialHints} · 跳过 ${s.skips}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const reportLine = (title: string, items: string[], warn: boolean): ReactNode => {
|
|
134
|
+
if (items.length === 0) return null
|
|
135
|
+
return (
|
|
136
|
+
<Card className={css.card}>
|
|
137
|
+
<strong className={warn ? css.warnText : undefined}>{title}({items.length})</strong>
|
|
138
|
+
<ul className={css.reportList}>
|
|
139
|
+
{items.map((item, i) => <li key={`${title}-${i}`}>{item}</li>)}
|
|
140
|
+
</ul>
|
|
141
|
+
</Card>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div className={css.viewBody}>
|
|
147
|
+
<SectionTitle title={t('snapshots.title')} subtitle={t('snapshots.hint')} />
|
|
148
|
+
|
|
149
|
+
{state.status === 'loading' && <Spinner label={t('snapshots.loading')} />}
|
|
150
|
+
|
|
151
|
+
{state.status === 'error' && (
|
|
152
|
+
<Banner kind="error">
|
|
153
|
+
{state.error ?? t('common.unknownError')}
|
|
154
|
+
<Button variant="primary" onClick={load}>{t('common.retry')}</Button>
|
|
155
|
+
</Banner>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{state.status === 'ready' && state.metas.length === 0 && (
|
|
159
|
+
<Empty>{t('snapshots.empty')}</Empty>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{state.status === 'ready' && state.metas.length > 0 && (
|
|
163
|
+
<>
|
|
164
|
+
<div className={css.snapshotList} role="listbox" aria-label={t('snapshots.selectHint')}>
|
|
165
|
+
<div className={css.snapshotRowHeader}>
|
|
166
|
+
<span>{t('snapshots.createdAt')}</span>
|
|
167
|
+
<span>{t('snapshots.sourceZip')}</span>
|
|
168
|
+
<span>{t('snapshots.status')}</span>
|
|
169
|
+
<span>{t('snapshots.entries')}</span>
|
|
170
|
+
<span>{t('snapshots.plugins')}</span>
|
|
171
|
+
</div>
|
|
172
|
+
{state.metas.map((meta) => {
|
|
173
|
+
const selected = meta.id === state.selectedId
|
|
174
|
+
return (
|
|
175
|
+
<button
|
|
176
|
+
key={meta.id}
|
|
177
|
+
type="button"
|
|
178
|
+
role="option"
|
|
179
|
+
aria-selected={selected}
|
|
180
|
+
data-active={selected ? '' : undefined}
|
|
181
|
+
className={css.snapshotRow}
|
|
182
|
+
onClick={() => { select(meta.id) }}
|
|
183
|
+
>
|
|
184
|
+
<span title={meta.id}>{new Date(meta.createdAt).toLocaleString()}</span>
|
|
185
|
+
<span title={meta.sourceZip}>{meta.sourceZip}</span>
|
|
186
|
+
<span><Badge kind={statusBadgeKind(meta.status)}>{statusLabel(t, meta.status)}</Badge></span>
|
|
187
|
+
<span>{meta.entryCount}</span>
|
|
188
|
+
<span>{meta.beforePluginCount}</span>
|
|
189
|
+
</button>
|
|
190
|
+
)
|
|
191
|
+
})}
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{state.selectedId !== null && (
|
|
195
|
+
<>
|
|
196
|
+
{state.planning && <Spinner label={t('common.loading')} />}
|
|
197
|
+
{state.plan !== null && (
|
|
198
|
+
<Card>
|
|
199
|
+
<SectionTitle title={t('snapshots.planTitle')} subtitle={summary()} />
|
|
200
|
+
{state.plan.actions.length === 0 && <Empty>{t('snapshots.noActions')}</Empty>}
|
|
201
|
+
<ul className={css.reportList}>
|
|
202
|
+
{state.plan.actions.map((action, i) => (
|
|
203
|
+
<li key={`plan-${i}`}>
|
|
204
|
+
<span className={css.kindTag}>{actionKindLabel(action.kind)}</span>
|
|
205
|
+
{' '}{action.description}
|
|
206
|
+
{action.detail !== undefined && <span className={css.hint}>({action.detail})</span>}
|
|
207
|
+
</li>
|
|
208
|
+
))}
|
|
209
|
+
</ul>
|
|
210
|
+
<div className={css.rowActions}>
|
|
211
|
+
<Button
|
|
212
|
+
variant="danger"
|
|
213
|
+
disabled={state.running || state.plan.actions.every((a) => a.kind === 'skip')}
|
|
214
|
+
onClick={execute}
|
|
215
|
+
title={t('snapshots.confirmRestore')}
|
|
216
|
+
>
|
|
217
|
+
{state.running ? t('snapshots.executing') : t('snapshots.execute')}
|
|
218
|
+
</Button>
|
|
219
|
+
</div>
|
|
220
|
+
{state.actionError !== null && <Banner kind="error">{state.actionError}</Banner>}
|
|
221
|
+
</Card>
|
|
222
|
+
)}
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
{state.report !== null && (
|
|
227
|
+
<>
|
|
228
|
+
<SectionTitle title={t('snapshots.reportTitle')} />
|
|
229
|
+
{reportLine(t('snapshots.restored'), state.report.restored, false)}
|
|
230
|
+
{reportLine(t('snapshots.removedPlugins'), state.report.removedPlugins, false)}
|
|
231
|
+
{reportLine(t('snapshots.manualHints'), state.report.manualHints, true)}
|
|
232
|
+
{reportLine(t('snapshots.failed'), state.report.failed.map((f) => `${f.item}: ${f.reason}`), true)}
|
|
233
|
+
{reportLine(t('snapshots.skipped'), state.report.skipped, false)}
|
|
234
|
+
</>
|
|
235
|
+
)}
|
|
236
|
+
</>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程同步设置区块(settings.section: config-manager-sync)。
|
|
3
|
+
*
|
|
4
|
+
* 独立 settings 页(不触碰并行会话的 ConfigManagerSection.tsx 三 tab 容器):
|
|
5
|
+
* - 仓库配置表单:repoUrl(必填)+ 认证 token(可选,写入 DSH credentials 的提示)
|
|
6
|
+
* + gitBin(可选);
|
|
7
|
+
* - 私有仓库强制提示横幅(常驻);
|
|
8
|
+
* - 推送按钮 → SyncPushReport(快照 id / 分区 / 告警);
|
|
9
|
+
* - 拉取按钮 → SyncPullReport.changes 差异摘要(description/kind/severity)+
|
|
10
|
+
* 「预览不执行导入」提示;needsReview 高亮(v1 不做完整导入接线);
|
|
11
|
+
* - 状态行:凭据配置 + 上次同步时间 + 通道(来自 GET /sync/status,组件挂载时加载)。
|
|
12
|
+
*
|
|
13
|
+
* 全部渲染模型来自 ./sync-view.ts 纯函数(node 单测覆盖),组件只做装配;
|
|
14
|
+
* 状态组件内自持(低频显式操作,同 SnapshotsPanel 策略,不进 sessionStorage);
|
|
15
|
+
* token 仅内存(state),成功后清空(已写入 DSH credentials),绝不持久化。
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect, useState } from 'react'
|
|
18
|
+
import type { ChangeEvent } from 'react'
|
|
19
|
+
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
20
|
+
import type { TranslateNS } from '../client-types.ts'
|
|
21
|
+
import type { SyncPullReport, SyncPushReport } from '../../sync/sync-engine.ts'
|
|
22
|
+
import { Badge, Banner, Button, Card, SectionTitle, Spinner } from '../common/ui.tsx'
|
|
23
|
+
import { ErrorBanner } from '../common/ErrorBanner.tsx'
|
|
24
|
+
import { SYNC_CREDENTIAL_REF } from './sync-api.ts'
|
|
25
|
+
import type { SyncApi, SyncStatusResponse } from './sync-api.ts'
|
|
26
|
+
import {
|
|
27
|
+
PRIVATE_REPO_HINT, computeSyncButtons, computeSyncStatus, kindLabel,
|
|
28
|
+
pullReportView, pushReportView, severityLabel,
|
|
29
|
+
} from './sync-view.ts'
|
|
30
|
+
import css from '../config-manager.module.css'
|
|
31
|
+
|
|
32
|
+
export interface SyncSettingsSectionInjected {
|
|
33
|
+
api: SyncApi
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SyncSettingsViewProps =
|
|
37
|
+
& PropsRuntime<'settings.section'>
|
|
38
|
+
& SyncSettingsSectionInjected
|
|
39
|
+
& { t: TranslateNS<'config-manager-sync'> }
|
|
40
|
+
|
|
41
|
+
interface SyncUiState {
|
|
42
|
+
loading: boolean
|
|
43
|
+
loadError: string | null
|
|
44
|
+
statusInfo: SyncStatusResponse | null
|
|
45
|
+
repoUrl: string
|
|
46
|
+
gitBin: string
|
|
47
|
+
/** 仅内存:成功后清空(已写入 DSH credentials),绝不持久化 */
|
|
48
|
+
token: string
|
|
49
|
+
busy: 'push' | 'pull' | null
|
|
50
|
+
pushReport: SyncPushReport | null
|
|
51
|
+
pullReport: SyncPullReport | null
|
|
52
|
+
error: string | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const initial: SyncUiState = {
|
|
56
|
+
loading: true,
|
|
57
|
+
loadError: null,
|
|
58
|
+
statusInfo: null,
|
|
59
|
+
repoUrl: '',
|
|
60
|
+
gitBin: '',
|
|
61
|
+
token: '',
|
|
62
|
+
busy: null,
|
|
63
|
+
pushReport: null,
|
|
64
|
+
pullReport: null,
|
|
65
|
+
error: null,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function SyncSettingsView({ close, api, t }: SyncSettingsViewProps) {
|
|
69
|
+
const [state, setState] = useState<SyncUiState>(initial)
|
|
70
|
+
const patch = (p: Partial<SyncUiState>): void => setState((s) => ({ ...s, ...p }))
|
|
71
|
+
|
|
72
|
+
/** 挂载时读取同步状态(配置回填 + 上次同步时间 + 凭据状态) */
|
|
73
|
+
const loadStatus = async (): Promise<void> => {
|
|
74
|
+
patch({ loading: true, loadError: null })
|
|
75
|
+
try {
|
|
76
|
+
const info = await api.status()
|
|
77
|
+
patch({ loading: false, statusInfo: info, repoUrl: info.repoUrl ?? '', gitBin: info.gitBin ?? '' })
|
|
78
|
+
} catch (err) {
|
|
79
|
+
patch({ loading: false, loadError: err instanceof Error ? err.message : String(err) })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
void loadStatus()
|
|
85
|
+
// api 为注入单例(注册时创建),生命周期内稳定;仅挂载时加载一次
|
|
86
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
87
|
+
}, [])
|
|
88
|
+
|
|
89
|
+
/** 表单快照 → 请求体(token 为空串不携带;gitBin 空串不携带) */
|
|
90
|
+
const payload = (): { repoUrl: string; gitBin?: string; token?: string } => ({
|
|
91
|
+
repoUrl: state.repoUrl.trim(),
|
|
92
|
+
gitBin: state.gitBin.trim() !== '' ? state.gitBin.trim() : undefined,
|
|
93
|
+
token: state.token.trim() !== '' ? state.token : undefined,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const runPush = async (): Promise<void> => {
|
|
97
|
+
patch({ busy: 'push', error: null, pushReport: null, pullReport: null })
|
|
98
|
+
try {
|
|
99
|
+
const report = await api.push(payload())
|
|
100
|
+
// 成功即清空 token(已安全写入 DSH credentials);失败保留以便重试
|
|
101
|
+
patch({ busy: null, pushReport: report, token: report.ok ? '' : state.token })
|
|
102
|
+
} catch (err) {
|
|
103
|
+
patch({ busy: null, error: err instanceof Error ? err.message : String(err) })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const runPull = async (): Promise<void> => {
|
|
108
|
+
patch({ busy: 'pull', error: null, pullReport: null, pushReport: null })
|
|
109
|
+
try {
|
|
110
|
+
const report = await api.pull(payload())
|
|
111
|
+
patch({ busy: null, pullReport: report, token: '' })
|
|
112
|
+
} catch (err) {
|
|
113
|
+
patch({ busy: null, error: err instanceof Error ? err.message : String(err) })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const status = computeSyncStatus(state.statusInfo, state.loading, state.loadError)
|
|
118
|
+
const buttons = computeSyncButtons(state.busy, state.repoUrl)
|
|
119
|
+
const pushView = pushReportView(state.pushReport)
|
|
120
|
+
const pullView = pullReportView(state.pullReport)
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className={css.section}>
|
|
124
|
+
<div className={css.sectionHeader}>
|
|
125
|
+
<div className={css.viewTabs}>
|
|
126
|
+
<span role="tab" aria-selected="true" data-active className={css.viewTab}>{t('section.label')}</span>
|
|
127
|
+
</div>
|
|
128
|
+
<button type="button" className={css.iconButton} title={t('common.close')} aria-label={t('common.close')} onClick={close}>×</button>
|
|
129
|
+
</div>
|
|
130
|
+
<div className={css.sectionBody}>
|
|
131
|
+
<div className={css.viewBody}>
|
|
132
|
+
<SectionTitle title={t('section.label')} subtitle={t('section.description')} />
|
|
133
|
+
|
|
134
|
+
{/* 私有仓库强制提示 */}
|
|
135
|
+
<Banner kind="warn">{PRIVATE_REPO_HINT}</Banner>
|
|
136
|
+
|
|
137
|
+
{/* 仓库配置 */}
|
|
138
|
+
<Card>
|
|
139
|
+
<span className={css.groupLabel}>{t('config.title')}</span>
|
|
140
|
+
<label className={css.field}>
|
|
141
|
+
<span className={css.fieldLabel}>{t('config.repoUrl')}</span>
|
|
142
|
+
<input
|
|
143
|
+
type="text"
|
|
144
|
+
className={css.input}
|
|
145
|
+
value={state.repoUrl}
|
|
146
|
+
placeholder="https://github.com/user/private-repo.git"
|
|
147
|
+
disabled={state.busy !== null}
|
|
148
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => { patch({ repoUrl: e.target.value }) }}
|
|
149
|
+
/>
|
|
150
|
+
<span className={css.hint}>{t('config.repoUrlHint')}</span>
|
|
151
|
+
</label>
|
|
152
|
+
<label className={css.field}>
|
|
153
|
+
<span className={css.fieldLabel}>
|
|
154
|
+
{t('config.token')}
|
|
155
|
+
{' '}
|
|
156
|
+
{state.statusInfo?.credentialConfigured === true && <Badge kind="ok">{t('config.tokenSaved')}</Badge>}
|
|
157
|
+
</span>
|
|
158
|
+
<input
|
|
159
|
+
type="password"
|
|
160
|
+
className={css.input}
|
|
161
|
+
value={state.token}
|
|
162
|
+
autoComplete="off"
|
|
163
|
+
placeholder="ghp_…(可选)"
|
|
164
|
+
disabled={state.busy !== null}
|
|
165
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => { patch({ token: e.target.value }) }}
|
|
166
|
+
/>
|
|
167
|
+
<span className={css.hint}>{t('config.tokenHint', { ref: SYNC_CREDENTIAL_REF })}</span>
|
|
168
|
+
</label>
|
|
169
|
+
<label className={css.field}>
|
|
170
|
+
<span className={css.fieldLabel}>{t('config.gitBin')}</span>
|
|
171
|
+
<input
|
|
172
|
+
type="text"
|
|
173
|
+
className={css.input}
|
|
174
|
+
value={state.gitBin}
|
|
175
|
+
placeholder="git"
|
|
176
|
+
disabled={state.busy !== null}
|
|
177
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => { patch({ gitBin: e.target.value }) }}
|
|
178
|
+
/>
|
|
179
|
+
<span className={css.hint}>{t('config.gitBinHint')}</span>
|
|
180
|
+
</label>
|
|
181
|
+
</Card>
|
|
182
|
+
|
|
183
|
+
{/* 同步状态 */}
|
|
184
|
+
<Card>
|
|
185
|
+
<span className={css.groupLabel}>{t('status.title')}</span>
|
|
186
|
+
<div className={css.statRow}>
|
|
187
|
+
<Badge kind={status.kind === 'ready' ? 'ok' : status.kind === 'error' ? 'error' : 'warn'}>{status.text}</Badge>
|
|
188
|
+
</div>
|
|
189
|
+
</Card>
|
|
190
|
+
|
|
191
|
+
{/* 操作 */}
|
|
192
|
+
<div className={css.actionRow}>
|
|
193
|
+
<Button variant="primary" disabled={!buttons.canPush} onClick={() => { void runPush() }}>
|
|
194
|
+
{state.busy === 'push' ? <Spinner label={buttons.pushLabel} /> : buttons.pushLabel}
|
|
195
|
+
</Button>
|
|
196
|
+
<Button disabled={!buttons.canPull} onClick={() => { void runPull() }}>
|
|
197
|
+
{state.busy === 'pull' ? <Spinner label={buttons.pullLabel} /> : buttons.pullLabel}
|
|
198
|
+
</Button>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{state.error !== null && <ErrorBanner error={state.error} />}
|
|
202
|
+
|
|
203
|
+
{/* 推送结果 */}
|
|
204
|
+
{pushView !== null && (
|
|
205
|
+
<Card>
|
|
206
|
+
<span className={css.groupLabel}>{t('push.title')}</span>
|
|
207
|
+
<Banner kind={pushView.kind === 'ok' ? 'ok' : 'error'}>{pushView.headline}</Banner>
|
|
208
|
+
{pushView.sections.length > 0 && (
|
|
209
|
+
<div>
|
|
210
|
+
<span className={css.fieldLabel}>{t('sections.title')}</span>
|
|
211
|
+
<div className={css.statRow}>
|
|
212
|
+
{pushView.sections.map((s) => <Badge key={s} kind="info">{s}</Badge>)}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
{pushView.warnings.length > 0 && (
|
|
217
|
+
<div>
|
|
218
|
+
<span className={css.fieldLabel}>{t('warnings.title')}</span>
|
|
219
|
+
<ul className={css.warnList}>
|
|
220
|
+
{pushView.warnings.map((w, i) => <li key={i}>{w}</li>)}
|
|
221
|
+
</ul>
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
</Card>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{/* 拉取差异预览 */}
|
|
228
|
+
{pullView !== null && (
|
|
229
|
+
<Card>
|
|
230
|
+
<span className={css.groupLabel}>{t('pull.title')}</span>
|
|
231
|
+
<Banner kind={pullView.kind === 'ok' ? 'info' : pullView.kind === 'empty' ? 'ok' : 'error'}>{pullView.headline}</Banner>
|
|
232
|
+
{pullView.summary !== null && (
|
|
233
|
+
<>
|
|
234
|
+
<div className={css.statRow}>
|
|
235
|
+
<Badge kind="info">{t('change.total', { total: pullView.summary.total })}</Badge>
|
|
236
|
+
{pullView.summary.error > 0 && <Badge kind="error">{severityLabel('error')} × {pullView.summary.error}</Badge>}
|
|
237
|
+
{pullView.summary.warning > 0 && <Badge kind="warn">{severityLabel('warning')} × {pullView.summary.warning}</Badge>}
|
|
238
|
+
{pullView.summary.info > 0 && <Badge kind="info">{severityLabel('info')} × {pullView.summary.info}</Badge>}
|
|
239
|
+
</div>
|
|
240
|
+
{pullView.summary.needsReview && <Banner kind="warn">{t('pull.needsReview')}</Banner>}
|
|
241
|
+
<div className={css.reportList}>
|
|
242
|
+
{pullView.summary.items.map((c) => (
|
|
243
|
+
<div key={c.id} className={css.statRow}>
|
|
244
|
+
<span className={css.kindTag}>{kindLabel(c.kind)}</span>
|
|
245
|
+
<Badge kind={c.severity === 'error' ? 'error' : c.severity === 'warning' ? 'warn' : 'info'}>
|
|
246
|
+
{severityLabel(c.severity)}
|
|
247
|
+
</Badge>
|
|
248
|
+
<span>{c.description}</span>
|
|
249
|
+
</div>
|
|
250
|
+
))}
|
|
251
|
+
</div>
|
|
252
|
+
</>
|
|
253
|
+
)}
|
|
254
|
+
{pullView.previewHint !== '' && <Banner kind="info">{pullView.previewHint}</Banner>}
|
|
255
|
+
</Card>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-ui:SyncApi(/api/dsh-config-manager/sync/*)请求契约测试。
|
|
3
|
+
* 用全局 fetch mock 验证 status/push/pull 的路径/方法/请求体与响应解析(含 4xx 错误映射)。
|
|
4
|
+
*/
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
|
|
8
|
+
import { ConfigManagerApiError } from '../api.ts';
|
|
9
|
+
import { SYNC_API, SyncApi } from './sync-api.ts';
|
|
10
|
+
|
|
11
|
+
interface FetchCall {
|
|
12
|
+
url: string;
|
|
13
|
+
init?: RequestInit;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function installFetchMock(handler: (call: FetchCall) => Response): void {
|
|
17
|
+
const original = globalThis.fetch;
|
|
18
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
19
|
+
return handler({ url: String(input), init });
|
|
20
|
+
}) as typeof fetch;
|
|
21
|
+
test.after(() => {
|
|
22
|
+
globalThis.fetch = original;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
27
|
+
return new Response(JSON.stringify(body), {
|
|
28
|
+
status,
|
|
29
|
+
headers: { 'content-type': 'application/json' },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('S-01 api.status():GET 到 /sync/status,解析配置/凭据/上次同步', async () => {
|
|
34
|
+
const body = {
|
|
35
|
+
ok: true, configured: true, repoUrl: 'https://github.com/u/r.git',
|
|
36
|
+
credentialConfigured: true, credentialWritable: true,
|
|
37
|
+
lastSyncAt: '2026-08-16T10:30:00.000Z', sectionCount: 3,
|
|
38
|
+
transport: { type: 'git', ref: 'main' },
|
|
39
|
+
};
|
|
40
|
+
let called: FetchCall | null = null;
|
|
41
|
+
installFetchMock((call) => {
|
|
42
|
+
called = call;
|
|
43
|
+
return jsonResponse(200, body);
|
|
44
|
+
});
|
|
45
|
+
// 经读取函数解除闭包赋值导致的 CFA 窄化(called 在回调内赋值,外部保持 null 窄化)
|
|
46
|
+
const lastCall = (): FetchCall | null => called;
|
|
47
|
+
|
|
48
|
+
const api = new SyncApi();
|
|
49
|
+
const result = await api.status();
|
|
50
|
+
assert.equal(result.configured, true);
|
|
51
|
+
assert.equal(result.repoUrl, 'https://github.com/u/r.git');
|
|
52
|
+
assert.equal(result.credentialConfigured, true);
|
|
53
|
+
assert.equal(lastCall()?.url, SYNC_API.status);
|
|
54
|
+
assert.equal(lastCall()?.init?.method, undefined, 'GET 不设 method');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('S-02 api.push():POST /sync/push,请求体携带 repoUrl/token/gitBin', async () => {
|
|
58
|
+
const calls: FetchCall[] = [];
|
|
59
|
+
installFetchMock((call) => {
|
|
60
|
+
calls.push(call);
|
|
61
|
+
return jsonResponse(200, { ok: true, snapshotId: 'sync-1', sections: ['settings'], warnings: [] });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const api = new SyncApi();
|
|
65
|
+
const result = await api.push({ repoUrl: 'https://github.com/u/r.git', token: 'ghp_secret', gitBin: 'git' });
|
|
66
|
+
assert.equal(result.ok, true);
|
|
67
|
+
assert.equal(result.snapshotId, 'sync-1');
|
|
68
|
+
assert.equal(calls.length, 1);
|
|
69
|
+
assert.equal(calls[0]?.url, SYNC_API.push);
|
|
70
|
+
assert.equal(calls[0]?.init?.method, 'POST');
|
|
71
|
+
const sent = JSON.parse(String(calls[0]?.init?.body ?? '{}')) as Record<string, unknown>;
|
|
72
|
+
assert.equal(sent['repoUrl'], 'https://github.com/u/r.git');
|
|
73
|
+
assert.equal(sent['token'], 'ghp_secret');
|
|
74
|
+
assert.equal(sent['gitBin'], 'git');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('S-03 api.pull():POST /sync/pull,strategy 缺省透传 merge,响应含差异摘要', async () => {
|
|
78
|
+
const calls: FetchCall[] = [];
|
|
79
|
+
installFetchMock((call) => {
|
|
80
|
+
calls.push(call);
|
|
81
|
+
return jsonResponse(200, {
|
|
82
|
+
ok: true, snapshotId: 'sync-9',
|
|
83
|
+
changes: [{ id: 'a', adapter: 'settings', kind: 'Conflict', description: '冲突', severity: 'warning' }],
|
|
84
|
+
needsReview: true,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const api = new SyncApi();
|
|
89
|
+
const result = await api.pull({ repoUrl: 'https://github.com/u/r.git', strategy: 'merge' });
|
|
90
|
+
assert.equal(result.changes.length, 1);
|
|
91
|
+
assert.equal(result.changes[0]?.kind, 'Conflict');
|
|
92
|
+
assert.equal(result.needsReview, true);
|
|
93
|
+
assert.equal(calls[0]?.url, SYNC_API.pull);
|
|
94
|
+
const sent = JSON.parse(String(calls[0]?.init?.body ?? '{}')) as Record<string, unknown>;
|
|
95
|
+
assert.equal(sent['strategy'], 'merge');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('S-04 错误映射:4xx 携带 error → ConfigManagerApiError', async () => {
|
|
99
|
+
installFetchMock(() => jsonResponse(400, { error: 'repoUrl is required' }));
|
|
100
|
+
const api = new SyncApi();
|
|
101
|
+
await assert.rejects(api.push({ repoUrl: '' }), (err: unknown) => {
|
|
102
|
+
assert.ok(err instanceof ConfigManagerApiError);
|
|
103
|
+
assert.match(err.message, /repoUrl is required/);
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('S-05 服务未挂载:404(非 JSON 体)→ ConfigManagerApiError 提示插件未加载', async () => {
|
|
109
|
+
// 路由不存在时宿主返回 404 但无 JSON error → readJson 兜底为「插件未挂载」提示
|
|
110
|
+
installFetchMock(() => new Response('Not Found', { status: 404 }));
|
|
111
|
+
const api = new SyncApi();
|
|
112
|
+
await assert.rejects(api.status(), (err: unknown) => {
|
|
113
|
+
assert.ok(err instanceof ConfigManagerApiError);
|
|
114
|
+
assert.match(err.message, /未挂载/);
|
|
115
|
+
return true;
|
|
116
|
+
});
|
|
117
|
+
});
|