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.
Files changed (107) hide show
  1. package/README.md +30 -0
  2. package/README.zh-CN.md +30 -0
  3. package/lib/adapters/test-helpers.d.ts +4 -2
  4. package/lib/adapters/test-helpers.js +11 -3
  5. package/lib/adapters/test-helpers.js.map +1 -1
  6. package/lib/cli/index.d.ts +44 -0
  7. package/lib/cli/index.js +230 -0
  8. package/lib/cli/index.js.map +1 -0
  9. package/lib/client.d.ts +260 -5
  10. package/lib/client.js +2519 -820
  11. package/lib/client.js.map +1 -1
  12. package/lib/core/analyzer.d.ts +18 -1
  13. package/lib/core/analyzer.js +29 -0
  14. package/lib/core/analyzer.js.map +1 -1
  15. package/lib/core/backup.d.ts +3 -1
  16. package/lib/core/backup.js +70 -0
  17. package/lib/core/backup.js.map +1 -1
  18. package/lib/core/exporter.d.ts +11 -0
  19. package/lib/core/exporter.js +11 -0
  20. package/lib/core/exporter.js.map +1 -1
  21. package/lib/core/importer.d.ts +3 -0
  22. package/lib/core/importer.js +1 -0
  23. package/lib/core/importer.js.map +1 -1
  24. package/lib/core/index.d.ts +1 -0
  25. package/lib/core/index.js +1 -0
  26. package/lib/core/index.js.map +1 -1
  27. package/lib/core/restore.d.ts +92 -0
  28. package/lib/core/restore.js +505 -0
  29. package/lib/core/restore.js.map +1 -0
  30. package/lib/core/run-registry.d.ts +65 -0
  31. package/lib/core/run-registry.js +120 -0
  32. package/lib/core/run-registry.js.map +1 -0
  33. package/lib/core/types.d.ts +20 -0
  34. package/lib/core/types.js.map +1 -1
  35. package/lib/index.d.ts +45 -0
  36. package/lib/index.js +442 -8
  37. package/lib/index.js.map +1 -1
  38. package/lib/sync/fs.d.ts +18 -0
  39. package/lib/sync/fs.js +47 -0
  40. package/lib/sync/fs.js.map +1 -0
  41. package/lib/sync/git/git-transport.d.ts +87 -0
  42. package/lib/sync/git/git-transport.js +333 -0
  43. package/lib/sync/git/git-transport.js.map +1 -0
  44. package/lib/sync/layout.d.ts +29 -0
  45. package/lib/sync/layout.js +166 -0
  46. package/lib/sync/layout.js.map +1 -0
  47. package/lib/sync/sync-config.d.ts +16 -0
  48. package/lib/sync/sync-config.js +67 -0
  49. package/lib/sync/sync-config.js.map +1 -0
  50. package/lib/sync/sync-engine.d.ts +96 -0
  51. package/lib/sync/sync-engine.js +236 -0
  52. package/lib/sync/sync-engine.js.map +1 -0
  53. package/lib/sync/sync-state.d.ts +31 -0
  54. package/lib/sync/sync-state.js +103 -0
  55. package/lib/sync/sync-state.js.map +1 -0
  56. package/lib/sync/transport.d.ts +50 -0
  57. package/lib/sync/transport.js +22 -0
  58. package/lib/sync/transport.js.map +1 -0
  59. package/package.json +4 -1
  60. package/src/adapters/test-helpers.ts +10 -3
  61. package/src/cli/index.ts +278 -0
  62. package/src/client/ConfigManagerSection.tsx +49 -14
  63. package/src/client/api.ts +53 -5
  64. package/src/client/client-types.ts +0 -6
  65. package/src/client/common/ProgressBar.tsx +31 -19
  66. package/src/client/common/progress-view-edge.test.ts +60 -0
  67. package/src/client/common/progress-view.test.ts +79 -0
  68. package/src/client/common/progress-view.ts +94 -0
  69. package/src/client/config-manager.module.css +99 -20
  70. package/src/client/export/ExportView.tsx +63 -49
  71. package/src/client/import/ImportWizardView.tsx +107 -66
  72. package/src/client/index.ts +22 -0
  73. package/src/client/locales.ts +53 -0
  74. package/src/client/run-store.test.ts +500 -0
  75. package/src/client/run-store.ts +772 -0
  76. package/src/client/snapshots/SnapshotsPanel.tsx +240 -0
  77. package/src/client/sync/SyncSettingsView.tsx +261 -0
  78. package/src/client/sync/sync-api.test.ts +117 -0
  79. package/src/client/sync/sync-api.ts +132 -0
  80. package/src/client/sync/sync-locales.ts +77 -0
  81. package/src/client/sync/sync-view.test.ts +211 -0
  82. package/src/client/sync/sync-view.ts +202 -0
  83. package/src/core/analyzer.ts +50 -1
  84. package/src/core/backup.ts +78 -2
  85. package/src/core/exporter.ts +21 -0
  86. package/src/core/importer.ts +4 -0
  87. package/src/core/index.ts +5 -0
  88. package/src/core/restore.ts +587 -0
  89. package/src/core/run-progress.test.ts +237 -0
  90. package/src/core/run-registry.test.ts +139 -0
  91. package/src/core/run-registry.ts +166 -0
  92. package/src/core/smoke.test.ts +5 -0
  93. package/src/core/types.ts +22 -0
  94. package/src/index.ts +476 -8
  95. package/src/security/security.test.ts +5 -0
  96. package/src/sync/fs.ts +46 -0
  97. package/src/sync/git/git-transport.test.ts +456 -0
  98. package/src/sync/git/git-transport.ts +390 -0
  99. package/src/sync/layout.test.ts +226 -0
  100. package/src/sync/layout.ts +183 -0
  101. package/src/sync/sync-config.ts +72 -0
  102. package/src/sync/sync-engine.test.ts +299 -0
  103. package/src/sync/sync-engine.ts +318 -0
  104. package/src/sync/sync-state.test.ts +118 -0
  105. package/src/sync/sync-state.ts +123 -0
  106. package/src/sync/transport.test.ts +110 -0
  107. package/src/sync/transport.ts +73 -0
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-config-manager CLI(M3,criterion m3-cli-works):
4
+ * 离线快照列表与恢复命令,零 DSH 运行时依赖(仅 node 内置 + 本项目 core 引擎,
5
+ * 引擎本身也只依赖 node 内置;绝不 import @deepseek-ai/*,peerDependencies 缺失也能跑)。
6
+ *
7
+ * 子命令:
8
+ * snapshots [--data-dir <dir>] 列出快照(listSnapshots)
9
+ * restore [--id <uuid>] [--dry-run] 恢复到导入前状态(planRestore 预览 / restore 执行)
10
+ * [--data-dir <dir>] [--profile <name>] [--settings <path>]
11
+ * --help | -h 显示用法
12
+ *
13
+ * 缺省数据目录 = $DSH_HOME/dsh-config-manager/snapshots($DSH_HOME 缺省 ~/.dsh)。
14
+ */
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { pathToFileURL } from 'node:url';
18
+
19
+ import {
20
+ listSnapshots, planRestore, restore,
21
+ type RestorePlan, type RestoreReport, type SnapshotMeta,
22
+ } from '../core/restore.ts';
23
+
24
+ /* ------------------------------------------------------------ 参数解析(纯函数) */
25
+
26
+ export type CliCommand = 'snapshots' | 'restore' | 'help';
27
+
28
+ export interface CliOptions {
29
+ command: CliCommand;
30
+ /** --data-dir:快照数据目录覆盖 */
31
+ dataDir?: string;
32
+ /** --id:目标快照 id(restore 缺省取最近非 rolled-back) */
33
+ id?: string;
34
+ /** --dry-run:只打印计划 */
35
+ dryRun: boolean;
36
+ /** --profile:管理的 DSH profile(缺省 web) */
37
+ profile: string;
38
+ /** --settings:覆盖 settings 文件路径 */
39
+ settings?: string;
40
+ }
41
+
42
+ export type ParseResult = { ok: true; options: CliOptions } | { ok: false; error: string };
43
+
44
+ const VALUE_FLAGS = new Map<string, 'dataDir' | 'id' | 'profile' | 'settings'>([
45
+ ['--data-dir', 'dataDir'],
46
+ ['--id', 'id'],
47
+ ['--profile', 'profile'],
48
+ ['--settings', 'settings'],
49
+ ]);
50
+
51
+ /** 仅 restore 子命令允许的参数 */
52
+ const RESTORE_ONLY_FLAGS = new Set(['--id', '--dry-run', '--profile', '--settings']);
53
+
54
+ /** 解析 CLI 参数(纯函数;help 返回 command:'help',未知/缺值返回错误) */
55
+ export function parseCli(argv: readonly string[]): ParseResult {
56
+ const command = argv[0];
57
+ if (command === undefined) return { ok: false, error: '缺少子命令 / missing subcommand' };
58
+ if (command === '--help' || command === '-h' || command === 'help') {
59
+ return { ok: true, options: { command: 'help', dryRun: false, profile: 'web' } };
60
+ }
61
+ if (command !== 'snapshots' && command !== 'restore') {
62
+ return { ok: false, error: `未知子命令 / unknown subcommand: ${command}` };
63
+ }
64
+
65
+ const options: CliOptions = { command, dryRun: false, profile: 'web' };
66
+ const rest = argv.slice(1);
67
+ for (let i = 0; i < rest.length; i += 1) {
68
+ const flag = rest[i]!;
69
+ if (flag === '--help' || flag === '-h') {
70
+ return { ok: true, options: { ...options, command: 'help' } };
71
+ }
72
+ if (command === 'snapshots' && RESTORE_ONLY_FLAGS.has(flag)) {
73
+ return { ok: false, error: `snapshots 子命令不支持参数 / flag not allowed here: ${flag}` };
74
+ }
75
+ if (flag === '--dry-run') {
76
+ options.dryRun = true;
77
+ continue;
78
+ }
79
+ const key = VALUE_FLAGS.get(flag);
80
+ if (key === undefined) {
81
+ return { ok: false, error: `未知参数 / unknown flag: ${flag}` };
82
+ }
83
+ const value = rest[i + 1];
84
+ if (value === undefined || value === '' || value.startsWith('-')) {
85
+ return { ok: false, error: `参数 ${flag} 缺少值 / missing value for ${flag}` };
86
+ }
87
+ options[key] = value;
88
+ i += 1;
89
+ }
90
+ return { ok: true, options };
91
+ }
92
+
93
+ /** $DSH_HOME:环境变量优先,缺省 ~/.dsh(dsh-home-paths 的简化规则,CLI 零依赖版) */
94
+ export function resolveDshHome(env: Record<string, string | undefined> = process.env): string {
95
+ const fromEnv = env.DSH_HOME;
96
+ if (fromEnv !== undefined && fromEnv !== '') return fromEnv;
97
+ return path.join(os.homedir(), '.dsh');
98
+ }
99
+
100
+ /** 快照数据目录:--data-dir 覆盖优先,缺省 $DSH_HOME/dsh-config-manager/snapshots */
101
+ export function resolveDataDir(flag: string | undefined, env: Record<string, string | undefined> = process.env): string {
102
+ if (flag !== undefined && flag !== '') return flag;
103
+ return path.join(resolveDshHome(env), 'dsh-config-manager', 'snapshots');
104
+ }
105
+
106
+ /** 快照 id 校验:拒绝路径分隔符/保留名(防 join 越界) */
107
+ export function validateSnapshotId(id: string): string {
108
+ if (id === '' || id === '.' || id === '..' || id.includes('/') || id.includes('\\')) {
109
+ throw new Error(`非法快照 id / invalid snapshot id: ${JSON.stringify(id)}`);
110
+ }
111
+ return id;
112
+ }
113
+
114
+ /** 缺省快照选择:metas 已按 createdAt 倒序,取第一个 status !== 'rolled-back' */
115
+ export function pickDefaultSnapshotId(metas: readonly SnapshotMeta[]): string | null {
116
+ const eligible = metas.find((m) => m.status !== 'rolled-back');
117
+ return eligible?.id ?? null;
118
+ }
119
+
120
+ /* ------------------------------------------------------------ 输出 */
121
+
122
+ export interface CliIo {
123
+ log: (s: string) => void;
124
+ error: (s: string) => void;
125
+ }
126
+
127
+ const defaultIo: CliIo = { log: (s) => console.log(s), error: (s) => console.error(s) };
128
+
129
+ /* ------------------------------------------------------------ 执行 */
130
+
131
+ export function printUsage(io: CliIo = defaultIo): void {
132
+ io.log(
133
+ [
134
+ 'dsh-config-manager — DSH Config Manager 离线恢复工具 / offline restore tool',
135
+ '',
136
+ '用法 / Usage:',
137
+ ' dsh-config-manager snapshots [--data-dir <dir>]',
138
+ ' 列出快照 / list snapshots',
139
+ ' dsh-config-manager restore [--id <uuid>] [--dry-run] [--data-dir <dir>]',
140
+ ' [--profile <name>] [--settings <path>]',
141
+ ' 恢复到导入前状态 / restore to pre-import state(--dry-run 只打印计划)',
142
+ ' dsh-config-manager --help | -h',
143
+ ' 显示帮助 / show help',
144
+ '',
145
+ '选项 / Options:',
146
+ ' --data-dir <dir> 快照数据目录(缺省 $DSH_HOME/dsh-config-manager/snapshots)',
147
+ ' --id <uuid> 目标快照 id(缺省取最近一个非 rolled-back 快照)',
148
+ ' --dry-run 只打印恢复计划,不执行 / print plan only',
149
+ ' --profile <name> 管理的 DSH profile(缺省 web)',
150
+ ' --settings <path> 覆盖 settings 文件路径',
151
+ ].join('\n'),
152
+ );
153
+ }
154
+
155
+ function printSnapshots(metas: SnapshotMeta[], io: CliIo): void {
156
+ if (metas.length === 0) {
157
+ io.log('(无快照 / no snapshots)');
158
+ return;
159
+ }
160
+ const headers = ['ID', 'CREATED_AT', 'SOURCE_ZIP', 'STATUS', 'ENTRIES', 'HOST_FILES', 'PLUGINS'];
161
+ const rows = metas.map((m) => [
162
+ m.id, m.createdAt, m.sourceZip, m.status ?? 'unknown',
163
+ String(m.entryCount), String(m.hostFileBackupCount), String(m.beforePluginCount),
164
+ ]);
165
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length)));
166
+ const line = (cells: string[]): string => cells.map((c, i) => c.padEnd(widths[i]!)).join(' ').trimEnd();
167
+ io.log(line(headers));
168
+ for (const r of rows) io.log(line(r));
169
+ }
170
+
171
+ function printPlan(plan: RestorePlan, io: CliIo): void {
172
+ io.log(`快照 / snapshot: ${plan.snapshotId}`);
173
+ io.log(`创建时间 / createdAt: ${plan.createdAt}`);
174
+ io.log(`来源备份 / sourceZip: ${plan.sourceZip}`);
175
+ io.log(`插件基线 / plugin baseline: ${plan.pluginBaselineConfirmed ? '已确认 / confirmed' : '缺失 / missing(不计划插件卸载)'}`);
176
+ io.log(`动作计划 / plan(${plan.actions.length} 项 actions):`);
177
+ for (const a of plan.actions) {
178
+ const detail = a.detail !== undefined ? `(${a.detail})` : '';
179
+ io.log(` [${a.kind.padEnd(14)}] ${a.description}${detail}`);
180
+ }
181
+ const s = plan.summary;
182
+ io.log(
183
+ `汇总 / summary: 整文件还原 ${s.hostFileRestores} · 整文件删除 ${s.hostFileRemoves}`
184
+ + ` · 插件卸载 ${s.pluginRemoves} · 文件还原 ${s.fileRestores} · 文件删除 ${s.fileRemoves}`
185
+ + ` · 凭据提示 ${s.credentialHints} · 跳过 ${s.skips}`,
186
+ );
187
+ }
188
+
189
+ function printReport(report: RestoreReport, io: CliIo): void {
190
+ io.log(`恢复完成 / restore done — 快照 ${report.snapshotId}`);
191
+ io.log(`已还原 / restored(${report.restored.length}):`);
192
+ for (const r of report.restored) io.log(` - ${r}`);
193
+ io.log(`已卸载插件 / removed plugins(${report.removedPlugins.length}):`);
194
+ for (const p of report.removedPlugins) io.log(` - ${p}`);
195
+ io.log(`需人工处理 / manual hints(${report.manualHints.length}):`);
196
+ for (const h of report.manualHints) io.log(` - ${h}`);
197
+ io.log(`失败 / failed(${report.failed.length}):`);
198
+ for (const f of report.failed) io.log(` - ${f.item}: ${f.reason}`);
199
+ io.log(`跳过 / skipped(${report.skipped.length}):`);
200
+ for (const sk of report.skipped) io.log(` - ${sk}`);
201
+ }
202
+
203
+ /**
204
+ * CLI 主流程(io/env 可注入供测试):
205
+ * snapshots → 列表;restore → dry-run 打印计划 / 执行并打印诚实报告;
206
+ * 失败项存在时 exit 1,否则 0。
207
+ */
208
+ export async function runCli(
209
+ argv: readonly string[],
210
+ io: CliIo = defaultIo,
211
+ env: Record<string, string | undefined> = process.env,
212
+ ): Promise<number> {
213
+ const parsed = parseCli(argv);
214
+ if (!parsed.ok) {
215
+ io.error(parsed.error);
216
+ printUsage(io);
217
+ return 1;
218
+ }
219
+ const { options } = parsed;
220
+ if (options.command === 'help') {
221
+ printUsage(io);
222
+ return 0;
223
+ }
224
+
225
+ const dataDir = resolveDataDir(options.dataDir, env);
226
+
227
+ if (options.command === 'snapshots') {
228
+ const metas = await listSnapshots(dataDir);
229
+ printSnapshots(metas, io);
230
+ return 0;
231
+ }
232
+
233
+ // restore
234
+ let id = options.id;
235
+ if (id === undefined) {
236
+ const picked = pickDefaultSnapshotId(await listSnapshots(dataDir));
237
+ if (picked === null) {
238
+ io.error('没有可用快照(无快照或全部已回滚)/ no usable snapshot (none or all rolled-back)');
239
+ return 1;
240
+ }
241
+ id = picked;
242
+ }
243
+ try {
244
+ validateSnapshotId(id);
245
+ } catch (err) {
246
+ io.error(err instanceof Error ? err.message : String(err));
247
+ return 1;
248
+ }
249
+
250
+ const snapshotDir = path.join(dataDir, id);
251
+ const restoreOptions = {
252
+ snapshotDir,
253
+ homeDir: resolveDshHome(env),
254
+ profile: options.profile,
255
+ settingsPath: options.settings,
256
+ };
257
+ if (options.dryRun) {
258
+ printPlan(await planRestore(restoreOptions), io);
259
+ return 0;
260
+ }
261
+ const report = await restore(restoreOptions);
262
+ printReport(report, io);
263
+ return report.failed.length > 0 ? 1 : 0;
264
+ }
265
+
266
+ /* 直接运行(bin / node src/cli/index.ts)时进入主流程;被 import(测试)时不自动执行 */
267
+ const isDirectRun = process.argv[1] !== undefined
268
+ && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
269
+ if (isDirectRun) {
270
+ runCli(process.argv.slice(2))
271
+ .then((code) => {
272
+ process.exitCode = code;
273
+ })
274
+ .catch((err) => {
275
+ console.error(`dsh-config-manager 执行失败 / failed: ${err instanceof Error ? err.message : String(err)}`);
276
+ process.exitCode = 1;
277
+ });
278
+ }
@@ -1,15 +1,21 @@
1
1
  /**
2
2
  * Config Manager 设置页(settings.section 入口的主页面容器)。
3
3
  *
4
- * owner props 由 settings shell 传入(close);业务面(api)由注册时的
5
- * inject face 注入;t 由 locale seat 注入。内部两个主视图:
4
+ * 业务面(api)由注册时的 inject face 注入;t 由 locale seat 注入。
5
+ * 关闭按钮由 settings shell 自带,本页不再渲染。内部两个主视图:
6
6
  * Export(Quick/Custom)与 Import(九步向导)。
7
+ *
8
+ * m2:主视图 tab(view)与全部子视图状态统一由模块级 runStore 持有
9
+ * (sessionStorage 持久化 + 切 tab/关面板不重建控制器实例);挂载时
10
+ * 经 GET /runs + 轮询 /progress 恢复进行中的 run(刷新/重开面板后)。
7
11
  */
8
- import { useState } from 'react'
12
+ import { useEffect, useSyncExternalStore, useState } from 'react'
9
13
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
10
14
  import type { ConfigManagerSectionInjected, TranslateNS } from './client-types.ts'
15
+ import { runStore, type MainView } from './run-store.ts'
11
16
  import { ExportView } from './export/ExportView.tsx'
12
17
  import { ImportWizardView } from './import/ImportWizardView.tsx'
18
+ import { SnapshotsPanel } from './snapshots/SnapshotsPanel.tsx'
13
19
  import css from './config-manager.module.css'
14
20
 
15
21
  export type ConfigManagerSectionProps =
@@ -17,13 +23,31 @@ export type ConfigManagerSectionProps =
17
23
  & ConfigManagerSectionInjected
18
24
  & { t: TranslateNS<'config-manager'> }
19
25
 
20
- type MainView = 'export' | 'import'
21
-
22
26
  /**
23
- * 设置页容器:Export / Import 双视图切换。
27
+ * 设置页容器:Export / Import / Snapshots 三视图切换。
28
+ * Export/Import 状态在模块级 store(切 tab 不丢失);快照恢复面板为低频显式
29
+ * 操作,其状态组件内自持(local state,不进 sessionStorage)。
24
30
  */
25
- export function ConfigManagerSection({ close, api, t }: ConfigManagerSectionProps) {
26
- const [view, setView] = useState<MainView>('export')
31
+ export function ConfigManagerSection({ api, t }: ConfigManagerSectionProps) {
32
+ const state = useSyncExternalStore(runStore.subscribe, runStore.getSnapshot)
33
+ const view = state.view
34
+ const [snapshotsOpen, setSnapshotsOpen] = useState(false)
35
+
36
+ // m2-resume:挂载时重新订阅进行中的 run(刷新 / 重开面板后服务端继续执行,
37
+ // 这里经 /runs 找回活跃 runId 再轮询 /progress);卸载时停止轮询,重开再订阅。
38
+ useEffect(() => {
39
+ void runStore.resume(api)
40
+ return () => {
41
+ runStore.stopResume()
42
+ }
43
+ }, [api])
44
+
45
+ const setView = (next: MainView): void => {
46
+ setSnapshotsOpen(false)
47
+ runStore.patch({ view: next })
48
+ }
49
+
50
+ const activeTab: MainView | 'snapshots' = snapshotsOpen ? 'snapshots' : view
27
51
 
28
52
  return (
29
53
  <div className={css.section}>
@@ -32,8 +56,8 @@ export function ConfigManagerSection({ close, api, t }: ConfigManagerSectionProp
32
56
  <button
33
57
  type="button"
34
58
  role="tab"
35
- aria-selected={view === 'export'}
36
- data-active={view === 'export' ? '' : undefined}
59
+ aria-selected={activeTab === 'export'}
60
+ data-active={activeTab === 'export' ? '' : undefined}
37
61
  className={css.viewTab}
38
62
  onClick={() => { setView('export') }}
39
63
  >
@@ -42,18 +66,29 @@ export function ConfigManagerSection({ close, api, t }: ConfigManagerSectionProp
42
66
  <button
43
67
  type="button"
44
68
  role="tab"
45
- aria-selected={view === 'import'}
46
- data-active={view === 'import' ? '' : undefined}
69
+ aria-selected={activeTab === 'import'}
70
+ data-active={activeTab === 'import' ? '' : undefined}
47
71
  className={css.viewTab}
48
72
  onClick={() => { setView('import') }}
49
73
  >
50
74
  {t('view.import')}
51
75
  </button>
76
+ <button
77
+ type="button"
78
+ role="tab"
79
+ aria-selected={activeTab === 'snapshots'}
80
+ data-active={snapshotsOpen ? '' : undefined}
81
+ className={css.viewTab}
82
+ onClick={() => { setSnapshotsOpen(true) }}
83
+ >
84
+ {t('view.snapshots')}
85
+ </button>
52
86
  </div>
53
- <button type="button" className={css.iconButton} title={t('common.close')} aria-label={t('common.close')} onClick={close}>×</button>
54
87
  </div>
55
88
  <div className={css.sectionBody}>
56
- {view === 'export' ? <ExportView api={api} t={t} /> : <ImportWizardView api={api} t={t} />}
89
+ {snapshotsOpen
90
+ ? <SnapshotsPanel api={api} t={t} />
91
+ : view === 'export' ? <ExportView api={api} t={t} /> : <ImportWizardView api={api} t={t} />}
57
92
  </div>
58
93
  </div>
59
94
  )
package/src/client/api.ts CHANGED
@@ -9,12 +9,14 @@
9
9
  * 端点契约(Host 半 `src/index.ts` 按此实现,见 CLIENT_DEPENDENCIES.md 的接口说明):
10
10
  * ```
11
11
  * GET /api/dsh-config-manager/status → ServiceStatus (健康/版本检查)
12
- * POST /api/dsh-config-manager/export → ExportResponse (body: ExportOptions)
12
+ * POST /api/dsh-config-manager/export → ExportResponse (body: ExportOptions;响应含 runId)
13
13
  * GET /api/dsh-config-manager/download?path=… → 文件流(content-disposition 文件名)
14
14
  * POST /api/dsh-config-manager/upload?name=… → UploadResponse (body: 原始字节)
15
15
  * POST /api/dsh-config-manager/analyze → ImportAnalysis (body: { zipPath })
16
16
  * POST /api/dsh-config-manager/plan → ImportPlan (body: { zipPath, decisions })
17
- * POST /api/dsh-config-manager/execute → ImportResult (body: { zipPath, plan, opts })
17
+ * POST /api/dsh-config-manager/execute → ImportResult (body: { zipPath, plan, opts };响应含 runId)
18
+ * GET /api/dsh-config-manager/progress?runId=… → RunState (run 实时状态:轮询/刷新恢复)
19
+ * GET /api/dsh-config-manager/runs → RunState[] (活跃 run 列表)
18
20
  * ```
19
21
  * 安全约束:
20
22
  * - 所有响应/错误文本在进入 UI 前经 `redact()`(见 common/ErrorBanner.tsx);
@@ -24,6 +26,8 @@
24
26
 
25
27
  import type { ImportAnalysis, ImportDecisions, ImportPlan, ImportResult } from '../core/types.ts';
26
28
  import type { ExportOptions, ExportReport } from '../core/types.ts';
29
+ import type { RestorePlan, RestoreReport, SnapshotMeta } from '../core/restore.ts';
30
+ import type { RunState } from '../core/run-registry.ts';
27
31
  import type { Manifest } from '../schema/types.ts';
28
32
 
29
33
  /** Host 半健康检查响应(plugin 版本 / DSH 版本 / 平台,用于主页横幅与兼容性说明) */
@@ -35,11 +39,13 @@ export interface ServiceStatus {
35
39
  arch: string;
36
40
  }
37
41
 
38
- /** export 端点响应(对齐 ExportFlow 的 ExportRunResult 前半部分) */
42
+ /** export 端点响应(对齐 ExportFlow 的 ExportRunResult 前半部分;runId 为 m1 run 注册表标识) */
39
43
  export interface ExportResponse {
40
44
  zipPath: string;
41
45
  manifest: Manifest;
42
46
  report: ExportReport;
47
+ /** m1:本次导出 run 的 id(可用 /progress 查询状态 / 刷新恢复) */
48
+ runId: string;
43
49
  }
44
50
 
45
51
  /** upload 端点响应:Host 把用户上传的 ZIP 存入受控临时目录,返回可被 analyze/plan/execute 引用的路径 */
@@ -63,6 +69,13 @@ export interface ExecutePayload {
63
69
  };
64
70
  }
65
71
 
72
+ /** restore 端点响应:dryRun=true 返回 plan;执行返回 report(与 CLI 一致的诚实报告) */
73
+ export interface RestoreResponse {
74
+ dryRun: boolean;
75
+ plan?: RestorePlan;
76
+ report?: RestoreReport;
77
+ }
78
+
66
79
  /** 路由族常量(集中管理,与 Host 半的路由前缀保持一致) */
67
80
  export const CONFIG_MANAGER_API = {
68
81
  base: '/api/dsh-config-manager',
@@ -73,6 +86,10 @@ export const CONFIG_MANAGER_API = {
73
86
  analyze: '/api/dsh-config-manager/analyze',
74
87
  plan: '/api/dsh-config-manager/plan',
75
88
  execute: '/api/dsh-config-manager/execute',
89
+ progress: '/api/dsh-config-manager/progress',
90
+ runs: '/api/dsh-config-manager/runs',
91
+ snapshots: '/api/dsh-config-manager/snapshots',
92
+ restore: '/api/dsh-config-manager/restore',
76
93
  } as const;
77
94
 
78
95
  /** 导出请求超时(ms):与 Host 半 ROUTE_TIMEOUT_MS 对齐,防止宿主卡死时 UI 无限等待 */
@@ -271,7 +288,7 @@ export class ConfigManagerApi {
271
288
  zipPath: string,
272
289
  plan: ImportPlan,
273
290
  opts: { confirm: boolean; secretInputs?: Record<string, string>; rollbackOnError: boolean },
274
- ): Promise<ImportResult> {
291
+ ): Promise<ImportResult & { runId: string }> {
275
292
  const payload: ExecutePayload = {
276
293
  zipPath,
277
294
  plan,
@@ -286,6 +303,37 @@ export class ConfigManagerApi {
286
303
  headers: { 'content-type': 'application/json' },
287
304
  body: JSON.stringify(payload),
288
305
  });
289
- return readJson<ImportResult>(response);
306
+ return readJson<ImportResult & { runId: string }>(response);
307
+ }
308
+
309
+ // ------------------------------------------------------------- run 进度
310
+ /** m1:查询单个 run 的实时状态(执行中轮询 / 刷新恢复用;404 = 已过保留期或不存在) */
311
+ async progress(runId: string): Promise<RunState> {
312
+ const response = await fetch(CONFIG_MANAGER_API.progress + query({ runId }));
313
+ return readJson<RunState>(response);
314
+ }
315
+
316
+ /** m1:列出当前活跃(running)的 run(刷新后重新订阅进行中任务的入口) */
317
+ async runs(): Promise<RunState[]> {
318
+ const response = await fetch(CONFIG_MANAGER_API.runs);
319
+ return readJson<RunState[]>(response);
320
+ }
321
+
322
+ // ------------------------------------------------------- 快照恢复(M4)
323
+ /** 列出全部快照元信息(createdAt 倒序;含 status/条目数/宿主文件数/插件数) */
324
+ async snapshots(): Promise<SnapshotMeta[]> {
325
+ const response = await fetch(CONFIG_MANAGER_API.snapshots);
326
+ const body = await readJson<{ snapshots: SnapshotMeta[] }>(response);
327
+ return body.snapshots;
328
+ }
329
+
330
+ /** 快照恢复:dryRun=true 只取动作计划(零写入);false 执行并返回诚实报告 */
331
+ async restoreSnapshot(snapshotId: string, dryRun: boolean): Promise<RestoreResponse> {
332
+ const response = await fetch(CONFIG_MANAGER_API.restore, {
333
+ method: 'POST',
334
+ headers: { 'content-type': 'application/json' },
335
+ body: JSON.stringify({ snapshotId, dryRun }),
336
+ });
337
+ return readJson<RestoreResponse>(response);
290
338
  }
291
339
  }
@@ -15,12 +15,6 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
15
15
  export type { ClientContext }
16
16
  export type { TranslateNS }
17
17
 
18
- /** settings.section 注册项的 owner props(shell 传入 close) */
19
- export interface SettingsSectionProps {
20
- /** 关闭设置面板(shell 拥有 open 状态) */
21
- close: () => void
22
- }
23
-
24
18
  /** 本插件 Client 半的注入业务面:每个 settings.section 注册项都拿到同一个 api 实例 */
25
19
  export interface ConfigManagerSectionInjected {
26
20
  api: import('./api.ts').ConfigManagerApi
@@ -1,44 +1,56 @@
1
1
  /**
2
- * 进度条(规范 §29,绑 src/ui/progress.ts)。
2
+ * 进度条(规范 §29 + m3 真实进度)。
3
3
  *
4
- * 控制器(ExportFlow / ImportWizard)在调用 core 前后发出 ProgressEvent,
5
- * 本组件按 stage 文案 + step/total 渲染进度条;未知阶段回退显示 id 本身。
4
+ * 数据流:
5
+ * - 控制器(ExportFlow / ImportWizard)onProgress 发出普通 ProgressEvent
6
+ * (阶段文案 + step/total),走兼容路径;
7
+ * - m3:store 经 /runs + /progress 轮询得到的 RunState 映射为 RunProgress
8
+ * (额外携带 section/sectionTotal/item/itemTotal/detail),渲染为
9
+ * 「分区徽章(settings · 3/12)+ 内部计数徽章(plugins · 6/18)+ 当前项名」。
10
+ *
11
+ * 所有换算逻辑在 progress-view.ts 的 computeProgressView()(纯函数,可单测);
12
+ * 本组件只做渲染。未知阶段回退显示 id 本身。
6
13
  */
7
- import { stageText } from '../../ui/progress.ts'
8
- import type { ProgressEvent } from '../../ui/types.ts'
14
+ import { computeProgressView } from './progress-view.ts'
15
+ import type { RunProgress } from './progress-view.ts'
9
16
  import css from '../config-manager.module.css'
10
17
 
11
18
  export interface ProgressBarProps {
12
- /** 当前阶段事件(null = 未开始) */
13
- event: ProgressEvent | null
19
+ /** 当前进度事件(null = 未开始);普通 ProgressEvent 亦兼容 */
20
+ event: RunProgress | null
14
21
  /** 是否正在执行(false 时显示为完成态) */
15
22
  active: boolean
16
23
  }
17
24
 
18
25
  /**
19
- * 进度条:阶段文字 + 百分比(有 step/total 时计算,否则不定态动画)。
26
+ * 进度条:阶段文字 + 分区/内部计数徽章 + 当前项名 + 百分比。
20
27
  */
21
28
  export function ProgressBar({ event, active }: ProgressBarProps) {
22
- const percent =
23
- event !== null && event.step !== undefined && event.total !== undefined && event.total > 0
24
- ? Math.round((event.step / event.total) * 100)
25
- : null
26
- const label = event !== null ? stageText(event.stage) : ''
29
+ const view = computeProgressView(event)
27
30
 
28
31
  return (
29
32
  <div className={css.progressBlock}>
30
33
  <div className={css.progressMeta}>
31
- <span className={css.progressLabel}>{label}</span>
32
- {event?.detail !== undefined && event.detail !== '' && (
33
- <span className={css.progressDetail}>{event.detail}</span>
34
+ <span className={css.progressLabel}>{view.label}</span>
35
+ {view.sectionBadge !== null && (
36
+ <span className={`${css.progressBadge} ${css.progressBadgeSection}`}>
37
+ {view.sectionBadge.label} · {view.sectionBadge.current}/{view.sectionBadge.total}
38
+ </span>
39
+ )}
40
+ {view.countBadge !== null && (
41
+ <span className={`${css.progressBadge} ${css.progressBadgeCount}`}>
42
+ {view.countBadge.label !== '' ? `${view.countBadge.label} · ` : ''}
43
+ {view.countBadge.current}/{view.countBadge.total}
44
+ </span>
34
45
  )}
35
- {percent !== null && <span className={css.progressPercent}>{percent}%</span>}
46
+ {view.detail !== null && <span className={css.progressDetail}>{view.detail}</span>}
47
+ {view.percent !== null && <span className={css.progressPercent}>{view.percent}%</span>}
36
48
  </div>
37
49
  <div className={css.progressTrack}>
38
- {percent !== null ? (
50
+ {view.percent !== null ? (
39
51
  <div
40
52
  className={`${css.progressBar} ${active ? '' : css.progressBarDone}`}
41
- style={{ width: `${percent}%` }}
53
+ style={{ width: `${view.percent}%` }}
42
54
  />
43
55
  ) : (
44
56
  <div className={`${css.progressBar} ${css.progressIndeterminate}`} />
@@ -0,0 +1,60 @@
1
+ /**
2
+ * m4 测试补齐(任务 t4):computeProgressView 边界补充。
3
+ *
4
+ * m3 主路径已由 progress-view.test.ts 覆盖(null/兼容路径/分区徽章/内部计数徽章/
5
+ * 冗余去重/未知阶段);本文件补充未覆盖的判定分支:
6
+ * - step/total 与 item/itemTotal 同时存在时百分比优先 step/total(文档优先级);
7
+ * - section 存在但 sectionTotal 为 0 → 无分区徽章(total 必须 >0);
8
+ * - itemTotal 为 0 → 无内部计数徽章且百分比回退不定态(除零防御);
9
+ * - section 有值但 item 为 null → 分区徽章不渲染(徽章需要 current 计数);
10
+ * - 百分比取整(Math.round,非截断)。
11
+ * 全部为纯函数断言,不改动 m3 业务代码。
12
+ */
13
+ import test from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+
16
+ import { computeProgressView } from './progress-view.ts'
17
+ import type { RunProgress } from './progress-view.ts'
18
+
19
+ test('m3-render 边界: step/total 与 item/itemTotal 并存时百分比优先 step/total', () => {
20
+ // 若误用 item/itemTotal 会算出 25%(3/12);文档行为是优先 step/total → 33%
21
+ const event: RunProgress = {
22
+ stage: 'exporting',
23
+ step: 1,
24
+ total: 3,
25
+ section: 'settings',
26
+ sectionTotal: 12,
27
+ item: 3,
28
+ itemTotal: 12,
29
+ detail: 'settings',
30
+ }
31
+ const view = computeProgressView(event)
32
+ assert.equal(view.percent, 33, '百分比取 step/total(1/3 → 33%)而非 item/itemTotal')
33
+ assert.deepEqual(view.sectionBadge, { label: 'settings', current: 3, total: 12 }, '徽章仍用分区计数')
34
+ })
35
+
36
+ test('m3-render 边界: sectionTotal 为 0 → 无分区徽章(total 必须 >0)', () => {
37
+ const view = computeProgressView({ stage: 'executing', section: 'plugins', sectionTotal: 0, item: 2, itemTotal: 10 })
38
+ assert.equal(view.sectionBadge, null, 'total=0 时不渲染分区徽章')
39
+ assert.deepEqual(view.countBadge, { label: 'plugins', current: 2, total: 10 }, '内部计数徽章不受影响')
40
+ })
41
+
42
+ test('m3-render 边界: itemTotal 为 0 → 无内部计数徽章且百分比不定态(除零防御)', () => {
43
+ const view = computeProgressView({ stage: 'executing', section: 'plugins', item: 0, itemTotal: 0, detail: 'x' })
44
+ assert.equal(view.countBadge, null, 'itemTotal=0 不渲染计数徽章')
45
+ assert.equal(view.percent, null, 'total=0 时百分比不定态(避免除零)')
46
+ assert.equal(view.detail, 'x', 'detail 与 section 不同时保留')
47
+ })
48
+
49
+ test('m3-render 边界: section 有值但 item 为 null → 分区徽章不渲染(徽章需 current)', () => {
50
+ const view = computeProgressView({ stage: 'executing', section: 'settings', sectionTotal: 5, item: null, itemTotal: 5 })
51
+ assert.equal(view.sectionBadge, null)
52
+ assert.equal(view.countBadge, null)
53
+ assert.equal(view.percent, null, '无 step/total 且 item null → 不定态')
54
+ })
55
+
56
+ test('m3-render 边界: 百分比取整(Math.round 而非截断)', () => {
57
+ assert.equal(computeProgressView({ stage: 'exporting', step: 1, total: 3 }).percent, 33)
58
+ assert.equal(computeProgressView({ stage: 'exporting', step: 2, total: 3 }).percent, 67)
59
+ assert.equal(computeProgressView({ stage: 'exporting', step: 1, total: 6 }).percent, 17)
60
+ })