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
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ import type { Context } from '@deepseek-ai/cordis'
46
46
  import { settingsNamespace } from '@deepseek-ai/dsh-settings'
47
47
  import type { SettingsProvider } from '@deepseek-ai/dsh-settings'
48
48
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
49
+ import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
49
50
  import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
50
51
  // Type-only: pull the Cordis Context augmentations (webServer / workspaceRegistry)
51
52
  // and the WebRoute contract without any runtime import.
@@ -56,6 +57,8 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace'
56
57
  import * as yaml from 'js-yaml'
57
58
 
58
59
  import { Exporter, FileSnapshotStore, Importer } from './core/index.ts'
60
+ import { listSnapshots, planRestore, type RestorePlan, type RestoreReport } from './core/restore.ts'
61
+ import { RunRegistry, type RunState } from './core/run-registry.ts'
59
62
  import {
60
63
  hasDshBundlePatch, installErrorFor, listInstalledPlugins, resolveProfileDir,
61
64
  resolveProfileNameFromArgv, runDshPlugin, validateProfileName,
@@ -68,6 +71,10 @@ import type {
68
71
  import { createAdapters, USER_PATCH_FILE } from './adapters/index.ts'
69
72
  import { createEncryptionProvider, decryptCredentials } from './security/index.ts'
70
73
  import { createHardenedZipParser } from './security/zip-security.ts'
74
+ import { GitTransport } from './sync/git/git-transport.ts'
75
+ import { SyncEngine } from './sync/sync-engine.ts'
76
+ import { loadSyncState } from './sync/sync-state.ts'
77
+ import { readSyncConfig, writeSyncConfig, validateRepoUrl } from './sync/sync-config.ts'
71
78
  import { MANIFEST_FILE, parseManifest } from './schema/manifest.ts'
72
79
  import { SECTION_IDS } from './schema/config.ts'
73
80
  import type { Manifest, SectionId, WorkspaceRecord } from './schema/types.ts'
@@ -84,7 +91,7 @@ export const name = 'config-manager'
84
91
  export const inject = ['settings', 'credentials']
85
92
 
86
93
  /** Plugin version, kept in sync with package.json ("version"). */
87
- const PLUGIN_VERSION = '0.1.11'
94
+ const PLUGIN_VERSION = '0.1.13'
88
95
 
89
96
  /** Plugin config (composition entry); the loader applies it as-is. */
90
97
  export interface Config {
@@ -107,8 +114,23 @@ const API = {
107
114
  analyze: '/api/dsh-config-manager/analyze',
108
115
  plan: '/api/dsh-config-manager/plan',
109
116
  execute: '/api/dsh-config-manager/execute',
117
+ progress: '/api/dsh-config-manager/progress',
118
+ runs: '/api/dsh-config-manager/runs',
119
+ snapshots: '/api/dsh-config-manager/snapshots',
120
+ restore: '/api/dsh-config-manager/restore',
121
+ // m-sync-ui:远程同步(Git 私有仓库通道)
122
+ syncStatus: '/api/dsh-config-manager/sync/status',
123
+ syncPush: '/api/dsh-config-manager/sync/push',
124
+ syncPull: '/api/dsh-config-manager/sync/pull',
110
125
  } as const
111
126
 
127
+ /**
128
+ * 同步 token 的 DSH credentials 引用名(POSIX env-var 形态,满足 CredentialRef 品牌要求)。
129
+ * token 只经 credentialRef 读写(写入由请求体触发,读取在每次 git 网络操作时 resolve),
130
+ * 永不进 repoUrl / argv / commit / 同步文件 / 日志。
131
+ */
132
+ const SYNC_CREDENTIAL_REF = 'DSH_CONFIG_MANAGER_SYNC_TOKEN'
133
+
112
134
  /** Cap on JSON request bodies (import plans can be large: 4 MB). */
113
135
  const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024
114
136
 
@@ -607,6 +629,7 @@ class ConfigManagerHostContext implements HostContext {
607
629
  readonly arch: string = process.arch
608
630
  readonly homeDir: string
609
631
  readonly dshVersion: string
632
+ readonly profile: string
610
633
  readonly log: Logger
611
634
  readonly settings: SettingsFacade
612
635
  readonly credentials: CredentialsFacade
@@ -618,6 +641,7 @@ class ConfigManagerHostContext implements HostContext {
618
641
  constructor(ctx: Context, homeDir: string, profile: string) {
619
642
  this.homeDir = homeDir
620
643
  this.dshVersion = resolveDshVersion(homeDir)
644
+ this.profile = profile
621
645
  const level = process.env.DSH_CONFIG_MANAGER_LOG_LEVEL
622
646
  this.log = createLogger({
623
647
  level: level === 'debug' || level === 'info' || level === 'warn' || level === 'error' ? level : 'info',
@@ -714,11 +738,177 @@ interface RoutesDeps {
714
738
  exportsDir: string
715
739
  tmpDir: string
716
740
  snapshotsDir: string
741
+ /** m1:导出/导入 run 注册表(跨请求共享,/progress 与 /runs 的单一事实源) */
742
+ runs: RunRegistry
743
+ /** m-sync-ui:同步状态/配置目录($DSH_HOME/dsh-config-manager/sync) */
744
+ syncDir: string
745
+ /** m-sync-ui:原始 DSH credentials(resolve token / set token / describe 状态) */
746
+ credentials: CredentialProvider
747
+ }
748
+
749
+ /* -------------------------------------------------- sync 路由(m-sync-ui) */
750
+
751
+ /** 同步路由可预期的请求级错误(status 缺省 400;引擎/传输失败走 500) */
752
+ export class SyncRouteError extends Error {
753
+ readonly status: number
754
+
755
+ constructor(message: string, status: number = 400) {
756
+ super(message)
757
+ this.name = 'SyncRouteError'
758
+ this.status = status
759
+ }
760
+ }
761
+
762
+ /** 同步路由错误统一出口:SyncRouteError 用其 status,其余 500(GitTransport 错误消息已脱敏) */
763
+ export function writeSyncRouteError(res: ServerResponse, error: unknown): void {
764
+ if (error instanceof SyncRouteError) {
765
+ writeJson(res, error.status, { error: error.message })
766
+ return
767
+ }
768
+ const message = error instanceof Error ? error.message : String(error)
769
+ writeJson(res, 500, { error: message })
770
+ }
771
+
772
+ /* -------------------------------------------------- restore 路由(M4) */
773
+
774
+ /** POST /restore 请求体校验(纯函数;snapshotId 拒绝路径分隔符防 join 越界)。 */
775
+ export type BuildRestoreBodyResult =
776
+ | { ok: true; value: { snapshotId: string; dryRun: boolean } }
777
+ | { ok: false; error: string }
778
+
779
+ export function buildRestoreBody(body: unknown): BuildRestoreBodyResult {
780
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
781
+ return { ok: false, error: 'invalid JSON body' }
782
+ }
783
+ const record = body as Record<string, unknown>
784
+ const snapshotId = record['snapshotId']
785
+ if (typeof snapshotId !== 'string' || snapshotId === '') {
786
+ return { ok: false, error: 'snapshotId is required' }
787
+ }
788
+ if (snapshotId === '.' || snapshotId === '..' || snapshotId.includes('/') || snapshotId.includes('\\')) {
789
+ return { ok: false, error: 'snapshotId 非法(禁止路径分隔符)/ invalid snapshotId' }
790
+ }
791
+ return { ok: true, value: { snapshotId, dryRun: record['dryRun'] === true } }
792
+ }
793
+
794
+ /**
795
+ * 宿主侧恢复动作执行器(真实执行 restore 计划):
796
+ * 整文件/文件还原与删除走 ctx.fs(home-relative facade,越界由 facade 再拦一道),
797
+ * blob 读取与 pre-restore 副本走快照目录(node fs),插件卸载走官方 dsh plugin CLI。
798
+ */
799
+ export interface RestoreExecutor {
800
+ /** 读快照目录内 blob(相对 snapshotDir) */
801
+ readBlob(blobPath: string): Promise<Uint8Array>
802
+ /** 把当前 home 文件内容复制到 <snapshotDir>/pre-restore/(覆盖/删除前的双保险) */
803
+ savePreRestore(relPath: string): Promise<void>
804
+ existsHome(relPath: string): Promise<boolean>
805
+ writeHome(relPath: string, data: Uint8Array): Promise<void>
806
+ removeHome(relPath: string): Promise<void>
807
+ /** 卸载插件(官方通道);失败返回 { ok:false, message } */
808
+ uninstallPlugin(name: string): Promise<{ ok: boolean; message?: string }>
809
+ }
810
+
811
+ /**
812
+ * 按计划执行恢复动作(纯执行器;逐项 try/catch 不拖垮其余),
813
+ * 返回与 CLI 一致的诚实报告。顺序 = 计划顺序(整文件 → 插件 → file 补偿)。
814
+ */
815
+ export async function executeRestorePlan(
816
+ plan: RestorePlan,
817
+ exec: RestoreExecutor,
818
+ ): Promise<RestoreReport> {
819
+ const report: RestoreReport = {
820
+ snapshotId: plan.snapshotId,
821
+ restored: [],
822
+ removedPlugins: [],
823
+ manualHints: [],
824
+ failed: [],
825
+ skipped: [],
826
+ }
827
+ for (const action of plan.actions) {
828
+ try {
829
+ switch (action.kind) {
830
+ case 'hostFileRestore':
831
+ case 'fileRestore': {
832
+ if (action.target === undefined || action.blobPath === undefined) {
833
+ throw new Error('恢复动作缺少 target/blobPath')
834
+ }
835
+ if (await exec.existsHome(action.target)) await exec.savePreRestore(action.target)
836
+ await exec.writeHome(action.target, await exec.readBlob(action.blobPath))
837
+ report.restored.push(action.target)
838
+ break
839
+ }
840
+ case 'hostFileRemove':
841
+ case 'fileRemove': {
842
+ if (action.target === undefined) throw new Error('恢复动作缺少 target')
843
+ if (await exec.existsHome(action.target)) {
844
+ await exec.savePreRestore(action.target)
845
+ await exec.removeHome(action.target)
846
+ }
847
+ report.restored.push(action.target)
848
+ break
849
+ }
850
+ case 'pluginRemove': {
851
+ if (action.pluginName === undefined) throw new Error('恢复动作缺少插件名')
852
+ const result = await exec.uninstallPlugin(action.pluginName)
853
+ if (result.ok) {
854
+ report.removedPlugins.push(action.pluginName)
855
+ } else {
856
+ report.failed.push({ item: `plugin:${action.pluginName}`, reason: result.message ?? '卸载失败' })
857
+ }
858
+ break
859
+ }
860
+ case 'credentialHint':
861
+ report.manualHints.push(action.manualHint ?? action.description)
862
+ break
863
+ case 'skip':
864
+ report.skipped.push(action.description)
865
+ break
866
+ default:
867
+ report.skipped.push(`未知动作 ${String(action.kind)}: ${action.description}`)
868
+ }
869
+ } catch (err) {
870
+ report.failed.push({
871
+ item: action.target ?? action.pluginName ?? action.description,
872
+ reason: err instanceof Error ? err.message : String(err),
873
+ })
874
+ }
875
+ }
876
+ return report
877
+ }
878
+
879
+ /** 宿主 restore 执行器装配:ctx.fs(home-relative)+ 快照目录(node fs)+ runDshPlugin。 */
880
+ function makeRestoreExecutor(snapshotDir: string, host: HostContext, profile: string): RestoreExecutor {
881
+ const profileDir = resolveProfileDir(host.homeDir, profile)
882
+ let seq = 0
883
+ return {
884
+ readBlob: async (blobPath) => {
885
+ const target = resolve(snapshotDir, blobPath)
886
+ if (!isSameOrChild(target, snapshotDir)) throw new Error(`blob 路径越界: ${blobPath}`)
887
+ return fs.readFile(target)
888
+ },
889
+ savePreRestore: async (relPath) => {
890
+ const data = await host.fs.readFile(relPath)
891
+ seq += 1
892
+ const safe = relPath.replace(/[\\/:*?"<>|]/g, '_')
893
+ await fs.mkdir(join(snapshotDir, 'pre-restore'), { recursive: true })
894
+ await fs.writeFile(join(snapshotDir, 'pre-restore', `${String(seq).padStart(4, '0')}-${safe}`), data)
895
+ },
896
+ existsHome: (relPath) => host.fs.exists(relPath),
897
+ writeHome: (relPath, data) => host.fs.writeFile(relPath, data),
898
+ removeHome: (relPath) => host.fs.remove(relPath),
899
+ uninstallPlugin: async (name) => {
900
+ const result = await runDshPlugin(profileDir, profile, ['remove', name])
901
+ if (result.exitCode === 0) return { ok: true }
902
+ const output = `${result.stderr}\n${result.stdout}`.trim()
903
+ const tail = output.split('\n').slice(-8).join('\n') || '无输出'
904
+ return { ok: false, message: `dsh plugin remove ${name} 失败(exit ${String(result.exitCode)}):${tail}` }
905
+ },
906
+ }
717
907
  }
718
908
 
719
909
  /** Build the /api/dsh-config-manager route family. */
720
910
  function makeRoutes(deps: RoutesDeps): WebRoute[] {
721
- const { host, adapters, exportsDir, tmpDir, snapshotsDir } = deps
911
+ const { host, adapters, exportsDir, tmpDir, snapshotsDir, runs, syncDir, credentials } = deps
722
912
  const roots = [exportsDir, tmpDir]
723
913
 
724
914
  const makeImporter = (): Importer => new Importer({
@@ -742,6 +932,56 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
742
932
  return true
743
933
  }
744
934
 
935
+ // ------------------------------------------------- sync 路由装配(m-sync-ui)
936
+ // 请求级装配:每次 push/pull 从请求体取 repoUrl/gitBin,token 非空先写入 DSH
937
+ // credentials(只存值不落盘同步文件/日志),git 网络操作时经 resolve 现取 ——
938
+ // 与 GitTransport「token 只从注入 provider 读取」的安全契约完全对齐。
939
+
940
+ /** 解析同步请求体(repoUrl 必填;token 非空则先写入 DSH credentials)。 */
941
+ const prepareSync = async (body: Record<string, unknown>): Promise<{ repoUrl: string; gitBin?: string }> => {
942
+ const repoUrl = typeof body['repoUrl'] === 'string' ? body['repoUrl'].trim() : ''
943
+ if (repoUrl === '') throw new SyncRouteError('repoUrl is required')
944
+ const urlError = validateRepoUrl(repoUrl)
945
+ if (urlError !== null) throw new SyncRouteError(urlError)
946
+ const gitBin = typeof body['gitBin'] === 'string' && body['gitBin'] !== '' ? body['gitBin'] : undefined
947
+ const token = typeof body['token'] === 'string' && body['token'] !== '' ? body['token'] : undefined
948
+ if (token !== undefined) {
949
+ try {
950
+ await credentials.set(credentialRef(SYNC_CREDENTIAL_REF), token)
951
+ } catch (error) {
952
+ const reason = error instanceof Error ? error.message : String(error)
953
+ throw new SyncRouteError(
954
+ `token 写入 DSH credentials 失败:${reason}(请在 DSH 凭据管理里配置 ${SYNC_CREDENTIAL_REF} 后重试)`,
955
+ )
956
+ }
957
+ }
958
+ return { repoUrl, gitBin }
959
+ }
960
+
961
+ /** 构造 SyncEngine(Git 私有仓库通道 + 散文件快照目录 + pull 用 Importer)。 */
962
+ const makeSyncEngine = (repoUrl: string, gitBin: string | undefined): SyncEngine => {
963
+ const transport = new GitTransport({
964
+ repoUrl,
965
+ workDir: join(syncDir, 'work'),
966
+ credentials: {
967
+ getToken: async () => {
968
+ const resolved = await credentials.resolve(credentialRef(SYNC_CREDENTIAL_REF))
969
+ return resolved?.value ?? ''
970
+ },
971
+ },
972
+ gitBin,
973
+ })
974
+ return new SyncEngine({
975
+ ctx: host,
976
+ transport,
977
+ stateDir: syncDir,
978
+ adapters,
979
+ importer: makeImporter(),
980
+ localSnapshotsDir: join(syncDir, 'snapshots'),
981
+ zipDir: tmpDir,
982
+ })
983
+ }
984
+
745
985
  return [
746
986
  // ------------------------------------------------------------- status
747
987
  {
@@ -776,22 +1016,45 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
776
1016
  // Encryption password is in-memory only (never persisted / logged).
777
1017
  const password = typeof body['password'] === 'string' && body['password'] !== '' ? body['password'] : undefined
778
1018
  const outPath = join(exportsDir, `dsh-config-${dateStamp()}-${randomBytes(3).toString('hex')}.zip`)
1019
+ // m1:执行开始注册 run(同 kind 已有进行中任务 → 409 拒绝,防止重复导出)
1020
+ let run: RunState
1021
+ try {
1022
+ run = runs.register('export')
1023
+ } catch (error) {
1024
+ writeJson(res, 409, { error: error instanceof Error ? error.message : String(error) })
1025
+ return
1026
+ }
1027
+ const runId = run.runId
779
1028
  try {
780
1029
  const exporter = new Exporter({
781
1030
  ctx: host,
782
1031
  adapters,
783
1032
  encryption: includeSecrets && password !== undefined ? createEncryptionProvider(password) : null,
784
1033
  exporterVersion: PLUGIN_VERSION,
1034
+ // m1 埋点:每导出一个分区实时更新 run 状态(/progress 轮询可见)
1035
+ onSection: (info) => {
1036
+ runs.update(runId, {
1037
+ section: info.section,
1038
+ sectionTotal: info.total,
1039
+ item: info.index,
1040
+ itemTotal: info.total,
1041
+ detail: info.section,
1042
+ })
1043
+ },
785
1044
  })
786
1045
  const result = await withTimeout(
787
1046
  exporter.export({ includeSecrets, only, outPath }),
788
1047
  ROUTE_TIMEOUT_MS,
789
1048
  '导出超时(5 分钟):导出过程未完成,请重试;若反复超时请检查 profile 依赖状态',
790
1049
  )
791
- writeJson(res, 200, { zipPath: result.zipPath, manifest: result.manifest, report: result.report })
1050
+ // 结束写结果:完成结果落账(供 /progress 查询与刷新恢复后下载)
1051
+ runs.finish(runId, { zipPath: result.zipPath, manifest: result.manifest, report: result.report })
1052
+ writeJson(res, 200, { zipPath: result.zipPath, manifest: result.manifest, report: result.report, runId })
792
1053
  } catch (error) {
793
- host.log.error('导出失败', { error: error instanceof Error ? error.message : String(error) })
794
- writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
1054
+ const message = error instanceof Error ? error.message : String(error)
1055
+ runs.fail(runId, message)
1056
+ host.log.error('导出失败', { error: message })
1057
+ writeJson(res, 500, { error: message, runId })
795
1058
  }
796
1059
  },
797
1060
  },
@@ -908,6 +1171,37 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
908
1171
  }
909
1172
  },
910
1173
  },
1174
+ // ------------------------------------------------------------ progress
1175
+ // m1:查询单个 run 的实时状态(轮询 / 刷新恢复用;runId 不可猜,走 loopback-only 守卫)
1176
+ {
1177
+ kind: 'exact',
1178
+ path: API.progress,
1179
+ handler: async (req, res) => {
1180
+ if (!guard(req, res, 'GET')) return
1181
+ const url = new URL(req.url ?? '/', 'http://localhost')
1182
+ const runId = queryParam(url, 'runId')
1183
+ if (runId === undefined || runId === '') {
1184
+ writeJson(res, 400, { error: 'runId query parameter is required' })
1185
+ return
1186
+ }
1187
+ const state = runs.get(runId)
1188
+ if (state === undefined) {
1189
+ writeJson(res, 404, { error: `run ${runId} not found(可能已过保留期被清理)` })
1190
+ return
1191
+ }
1192
+ writeJson(res, 200, state)
1193
+ },
1194
+ },
1195
+ // ----------------------------------------------------------------- runs
1196
+ // m1:列出当前活跃(running)的 run(刷新恢复时重新订阅进度用)
1197
+ {
1198
+ kind: 'exact',
1199
+ path: API.runs,
1200
+ handler: async (req, res) => {
1201
+ if (!guard(req, res, 'GET')) return
1202
+ writeJson(res, 200, runs.listActive())
1203
+ },
1204
+ },
911
1205
  // ------------------------------------------------------------- execute
912
1206
  {
913
1207
  kind: 'exact',
@@ -932,6 +1226,15 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
932
1226
  typeof opts['decryptPassword'] === 'string' && opts['decryptPassword'] !== ''
933
1227
  ? opts['decryptPassword']
934
1228
  : undefined
1229
+ // m1:执行开始注册 run(同 kind 已有进行中任务 → 409 拒绝,防止重复导入)
1230
+ let run: RunState
1231
+ try {
1232
+ run = runs.register('import')
1233
+ } catch (error) {
1234
+ writeJson(res, 409, { error: error instanceof Error ? error.message : String(error) })
1235
+ return
1236
+ }
1237
+ const runId = run.runId
935
1238
  try {
936
1239
  const decryptedCredentials = await tryDecryptCredentials(zipPath, decryptPassword)
937
1240
  const result = await makeImporter().executeImportPlan(zipPath, plan, {
@@ -942,14 +1245,168 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
942
1245
  : {},
943
1246
  rollbackOnError: opts['rollbackOnError'] === true,
944
1247
  decryptedCredentials,
1248
+ // m1 埋点:每完成一个计划项实时更新 run 状态(/progress 轮询可见)
1249
+ onItem: (info) => {
1250
+ runs.update(runId, {
1251
+ section: info.adapter,
1252
+ item: info.index,
1253
+ itemTotal: info.total,
1254
+ detail: info.detail ?? info.adapter,
1255
+ })
1256
+ },
945
1257
  })
946
- writeJson(res, 200, result)
1258
+ // 结束写结果:导入结果落账(供 /progress 查询与刷新恢复)
1259
+ runs.finish(runId, result)
1260
+ writeJson(res, 200, { ...result, runId })
1261
+ } catch (error) {
1262
+ const message = error instanceof Error ? error.message : String(error)
1263
+ runs.fail(runId, message)
1264
+ host.log.error('导入执行失败', { error: message })
1265
+ writeJson(res, 400, { error: message, runId })
1266
+ }
1267
+ },
1268
+ },
1269
+ // ---------------------------------------------------------- snapshots
1270
+ // M4:列出快照元信息(id/createdAt/sourceZip/status/计数,createdAt 倒序)
1271
+ {
1272
+ kind: 'exact',
1273
+ path: API.snapshots,
1274
+ handler: async (req, res) => {
1275
+ if (!guard(req, res, 'GET')) return
1276
+ try {
1277
+ writeJson(res, 200, { snapshots: await listSnapshots(snapshotsDir) })
1278
+ } catch (error) {
1279
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
1280
+ }
1281
+ },
1282
+ },
1283
+ // ------------------------------------------------------------ restore
1284
+ // M4:快照恢复。dryRun=true 只返回动作计划(planRestore,零写入);
1285
+ // 真实执行 = 计划 → 宿主执行器(ctx.fs 整文件/文件还原 + runDshPlugin 卸载插件)
1286
+ // → 与 CLI 一致的诚实报告 { restored/removedPlugins/manualHints/failed/skipped }。
1287
+ {
1288
+ kind: 'exact',
1289
+ path: API.restore,
1290
+ handler: async (req, res) => {
1291
+ if (!guard(req, res, 'POST')) return
1292
+ const body = await readJsonBody(req)
1293
+ const parsed = buildRestoreBody(body)
1294
+ if (!parsed.ok) {
1295
+ writeJson(res, 400, { error: parsed.error })
1296
+ return
1297
+ }
1298
+ const { snapshotId, dryRun } = parsed.value
1299
+ const snapshotDir = join(snapshotsDir, snapshotId)
1300
+ const restoreOpts = {
1301
+ snapshotDir,
1302
+ homeDir: host.homeDir,
1303
+ profile: host.profile,
1304
+ settingsPath: undefined,
1305
+ }
1306
+ try {
1307
+ if (dryRun) {
1308
+ writeJson(res, 200, { dryRun: true, plan: await planRestore(restoreOpts) })
1309
+ return
1310
+ }
1311
+ const plan = await planRestore(restoreOpts)
1312
+ const report = await executeRestorePlan(plan, makeRestoreExecutor(snapshotDir, host, host.profile))
1313
+ writeJson(res, 200, { dryRun: false, report })
947
1314
  } catch (error) {
948
- host.log.error('导入执行失败', { error: error instanceof Error ? error.message : String(error) })
949
1315
  writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
950
1316
  }
951
1317
  },
952
1318
  },
1319
+ // ------------------------------------------------------ sync/status
1320
+ // m-sync-ui:同步状态(仓库配置 / 凭据状态 / 上次同步 / 分区数)。只读,无 secret 值。
1321
+ {
1322
+ kind: 'exact',
1323
+ path: API.syncStatus,
1324
+ handler: async (req, res) => {
1325
+ if (!guard(req, res, 'GET')) return
1326
+ try {
1327
+ const [cfg, state, cred] = await Promise.all([
1328
+ readSyncConfig(syncDir),
1329
+ loadSyncState(syncDir),
1330
+ credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
1331
+ ])
1332
+ writeJson(res, 200, {
1333
+ ok: true,
1334
+ configured: cfg !== null,
1335
+ repoUrl: cfg?.repoUrl,
1336
+ gitBin: cfg?.gitBin,
1337
+ credentialConfigured: cred.configured,
1338
+ credentialWritable: cred.writable === true,
1339
+ lastSyncAt: state.lastSyncAt === '' ? undefined : state.lastSyncAt,
1340
+ sectionCount: Object.keys(state.sections).length,
1341
+ transport: state.transport,
1342
+ })
1343
+ } catch (error) {
1344
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
1345
+ }
1346
+ },
1347
+ },
1348
+ // ------------------------------------------------------ sync/push
1349
+ // m-sync-ui:推送(导出 portable 分区 → 提交私有仓库 → 更新 sync-state)。
1350
+ // token 可选:非空先写入 DSH credentials;成功则记忆仓库配置(回填表单用)。
1351
+ {
1352
+ kind: 'exact',
1353
+ path: API.syncPush,
1354
+ handler: async (req, res) => {
1355
+ if (!guard(req, res, 'POST')) return
1356
+ const body = await readJsonBody(req)
1357
+ if (body === undefined) {
1358
+ writeJson(res, 400, { error: 'invalid JSON body' })
1359
+ return
1360
+ }
1361
+ try {
1362
+ const { repoUrl, gitBin } = await prepareSync(body)
1363
+ const engine = makeSyncEngine(repoUrl, gitBin)
1364
+ const snapshotId =
1365
+ typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined
1366
+ const report = await withTimeout(
1367
+ engine.push(snapshotId === undefined ? {} : { snapshotId }),
1368
+ ROUTE_TIMEOUT_MS,
1369
+ '同步推送超时(5 分钟):请检查网络与仓库可达性后重试',
1370
+ )
1371
+ await writeSyncConfig(syncDir, { repoUrl, gitBin })
1372
+ writeJson(res, 200, report)
1373
+ } catch (error) {
1374
+ writeSyncRouteError(res, error)
1375
+ }
1376
+ },
1377
+ },
1378
+ // ------------------------------------------------------ sync/pull
1379
+ // m-sync-ui:拉取差异预览(只读:list/download → 转临时 ZIP → Importer 分析出计划摘要)。
1380
+ // 绝不直接写配置、绝不执行导入(executeImportPlan 由上层按用户确认驱动)。
1381
+ {
1382
+ kind: 'exact',
1383
+ path: API.syncPull,
1384
+ handler: async (req, res) => {
1385
+ if (!guard(req, res, 'POST')) return
1386
+ const body = await readJsonBody(req)
1387
+ if (body === undefined) {
1388
+ writeJson(res, 400, { error: 'invalid JSON body' })
1389
+ return
1390
+ }
1391
+ try {
1392
+ const { repoUrl, gitBin } = await prepareSync(body)
1393
+ const engine = makeSyncEngine(repoUrl, gitBin)
1394
+ const strategy =
1395
+ body['strategy'] === 'replace' || body['strategy'] === 'skipExisting' ? body['strategy'] : 'merge'
1396
+ const snapshotId =
1397
+ typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined
1398
+ const report = await withTimeout(
1399
+ engine.pull({ strategy, ...(snapshotId === undefined ? {} : { snapshotId }) }),
1400
+ ROUTE_TIMEOUT_MS,
1401
+ '同步拉取超时(5 分钟):请检查网络与仓库可达性后重试',
1402
+ )
1403
+ await writeSyncConfig(syncDir, { repoUrl, gitBin })
1404
+ writeJson(res, 200, report)
1405
+ } catch (error) {
1406
+ writeSyncRouteError(res, error)
1407
+ }
1408
+ },
1409
+ },
953
1410
  ]
954
1411
  }
955
1412
 
@@ -971,9 +1428,11 @@ export function apply(ctx: Context, config?: Config): void {
971
1428
  const exportsDir = join(dataDir, 'exports')
972
1429
  const tmpDir = join(dataDir, 'tmp')
973
1430
  const snapshotsDir = join(dataDir, 'snapshots')
1431
+ const syncDir = join(dataDir, 'sync')
974
1432
  mkdirSync(exportsDir, { recursive: true })
975
1433
  mkdirSync(tmpDir, { recursive: true })
976
1434
  mkdirSync(snapshotsDir, { recursive: true })
1435
+ mkdirSync(syncDir, { recursive: true })
977
1436
 
978
1437
  const host = new ConfigManagerHostContext(ctx, homeDir, resolveProfileName(config))
979
1438
  const adapters = createAdapters({
@@ -988,7 +1447,16 @@ export function apply(ctx: Context, config?: Config): void {
988
1447
  adapters: adapters.map((a) => a.id),
989
1448
  })
990
1449
 
991
- const routes = makeRoutes({ host, adapters, exportsDir, tmpDir, snapshotsDir })
1450
+ const routes = makeRoutes({
1451
+ host,
1452
+ adapters,
1453
+ exportsDir,
1454
+ tmpDir,
1455
+ snapshotsDir,
1456
+ runs: new RunRegistry(),
1457
+ syncDir,
1458
+ credentials: ctx.credentials,
1459
+ })
992
1460
  const webServer = readService<WebServer>(ctx, 'webServer')
993
1461
  if (webServer === undefined) {
994
1462
  host.log.warn('webServer 服务不可用:跳过 /api/dsh-config-manager 路由注册(引擎能力仍可用)')
@@ -216,6 +216,11 @@ class MemSnapshotStore implements SnapshotStore {
216
216
  return s;
217
217
  }
218
218
  async readBlob() { return new Uint8Array(); }
219
+ async updateStatus(id: string, status: Snapshot['status']) {
220
+ const s = this.snapshots.get(id);
221
+ if (!s) throw new Error(`snapshot not found: ${id}`);
222
+ s.status = status;
223
+ }
219
224
  }
220
225
 
221
226
  class MockHostContext implements HostContext {
package/src/sync/fs.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 可注入的最小 fs 门面(同步层核心逻辑经此访问磁盘)。
3
+ * 默认实现包 node:fs/promises;测试/内存场景可注入自定义实现(零副作用、纯接口)。
4
+ */
5
+ import fs from 'node:fs/promises';
6
+
7
+ /** 同步层所需的文件系统操作最小集(路径均为宿主文件系统绝对路径) */
8
+ export interface SnapshotFs {
9
+ readFile(p: string): Promise<Uint8Array>;
10
+ writeFile(p: string, data: Uint8Array): Promise<void>;
11
+ /** 递归创建目录(已存在则成功) */
12
+ mkdir(p: string): Promise<void>;
13
+ /** 直接子项名字(含目录名);目录不存在 → [] */
14
+ readdir(p: string): Promise<string[]>;
15
+ isDir(p: string): Promise<boolean>;
16
+ exists(p: string): Promise<boolean>;
17
+ /** 递归删除(不存在则成功) */
18
+ remove(p: string): Promise<void>;
19
+ }
20
+
21
+ /** 目录 + 正斜杠相对路径 → 宿主路径(正斜杠连接,Windows 下 Node fs 同样接受;
22
+ * 两侧多余斜杠一律清除,避免产生 '//' 段与空路径段) */
23
+ export function joinFs(dir: string, rel: string): string {
24
+ const base = dir.replace(/[\\/]+$/, '');
25
+ const cleanRel = rel.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '');
26
+ return cleanRel === '' ? base : `${base}/${cleanRel}`;
27
+ }
28
+
29
+ /** 默认 node:fs/promises 适配器 */
30
+ export function createSnapshotFs(): SnapshotFs {
31
+ return {
32
+ async readFile(p) { return fs.readFile(p); },
33
+ async writeFile(p, data) { await fs.writeFile(p, data); },
34
+ async mkdir(p) { await fs.mkdir(p, { recursive: true }); },
35
+ async readdir(p) {
36
+ try { return (await fs.readdir(p)).sort(); } catch { return []; }
37
+ },
38
+ async isDir(p) {
39
+ try { return (await fs.stat(p)).isDirectory(); } catch { return false; }
40
+ },
41
+ async exists(p) {
42
+ try { await fs.access(p); return true; } catch { return false; }
43
+ },
44
+ async remove(p) { await fs.rm(p, { recursive: true, force: true }); },
45
+ };
46
+ }