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/lib/index.js CHANGED
@@ -44,10 +44,16 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials';
44
44
  import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths';
45
45
  import * as yaml from 'js-yaml';
46
46
  import { Exporter, FileSnapshotStore, Importer } from "./core/index.js";
47
+ import { listSnapshots, planRestore } from "./core/restore.js";
48
+ import { RunRegistry } from "./core/run-registry.js";
47
49
  import { hasDshBundlePatch, installErrorFor, listInstalledPlugins, resolveProfileDir, resolveProfileNameFromArgv, runDshPlugin, validateProfileName, } from "./core/plugin-cli.js";
48
50
  import { createAdapters, USER_PATCH_FILE } from "./adapters/index.js";
49
51
  import { createEncryptionProvider, decryptCredentials } from "./security/index.js";
50
52
  import { createHardenedZipParser } from "./security/zip-security.js";
53
+ import { GitTransport } from "./sync/git/git-transport.js";
54
+ import { SyncEngine } from "./sync/sync-engine.js";
55
+ import { loadSyncState } from "./sync/sync-state.js";
56
+ import { readSyncConfig, writeSyncConfig, validateRepoUrl } from "./sync/sync-config.js";
51
57
  import { MANIFEST_FILE, parseManifest } from "./schema/manifest.js";
52
58
  import { SECTION_IDS } from "./schema/config.js";
53
59
  import { parseZip } from "./utils/zip.js";
@@ -59,7 +65,7 @@ export const name = 'config-manager';
59
65
  /** Services required before the engine can mount (present in every profile). */
60
66
  export const inject = ['settings', 'credentials'];
61
67
  /** Plugin version, kept in sync with package.json ("version"). */
62
- const PLUGIN_VERSION = '0.1.11';
68
+ const PLUGIN_VERSION = '0.1.13';
63
69
  /* ---------------------------------------------------------------- constants */
64
70
  /** Route family — must match the browser half's CONFIG_MANAGER_API exactly. */
65
71
  const API = {
@@ -70,7 +76,21 @@ const API = {
70
76
  analyze: '/api/dsh-config-manager/analyze',
71
77
  plan: '/api/dsh-config-manager/plan',
72
78
  execute: '/api/dsh-config-manager/execute',
79
+ progress: '/api/dsh-config-manager/progress',
80
+ runs: '/api/dsh-config-manager/runs',
81
+ snapshots: '/api/dsh-config-manager/snapshots',
82
+ restore: '/api/dsh-config-manager/restore',
83
+ // m-sync-ui:远程同步(Git 私有仓库通道)
84
+ syncStatus: '/api/dsh-config-manager/sync/status',
85
+ syncPush: '/api/dsh-config-manager/sync/push',
86
+ syncPull: '/api/dsh-config-manager/sync/pull',
73
87
  };
88
+ /**
89
+ * 同步 token 的 DSH credentials 引用名(POSIX env-var 形态,满足 CredentialRef 品牌要求)。
90
+ * token 只经 credentialRef 读写(写入由请求体触发,读取在每次 git 网络操作时 resolve),
91
+ * 永不进 repoUrl / argv / commit / 同步文件 / 日志。
92
+ */
93
+ const SYNC_CREDENTIAL_REF = 'DSH_CONFIG_MANAGER_SYNC_TOKEN';
74
94
  /** Cap on JSON request bodies (import plans can be large: 4 MB). */
75
95
  const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024;
76
96
  /** Cap on raw upload bodies (staged to the controlled tmp dir). */
@@ -544,6 +564,7 @@ class ConfigManagerHostContext {
544
564
  arch = process.arch;
545
565
  homeDir;
546
566
  dshVersion;
567
+ profile;
547
568
  log;
548
569
  settings;
549
570
  credentials;
@@ -554,6 +575,7 @@ class ConfigManagerHostContext {
554
575
  constructor(ctx, homeDir, profile) {
555
576
  this.homeDir = homeDir;
556
577
  this.dshVersion = resolveDshVersion(homeDir);
578
+ this.profile = profile;
557
579
  const level = process.env.DSH_CONFIG_MANAGER_LOG_LEVEL;
558
580
  this.log = createLogger({
559
581
  level: level === 'debug' || level === 'info' || level === 'warn' || level === 'error' ? level : 'info',
@@ -643,9 +665,142 @@ async function tryDecryptCredentials(zipPath, password) {
643
665
  }
644
666
  return map;
645
667
  }
668
+ /* -------------------------------------------------- sync 路由(m-sync-ui) */
669
+ /** 同步路由可预期的请求级错误(status 缺省 400;引擎/传输失败走 500) */
670
+ export class SyncRouteError extends Error {
671
+ status;
672
+ constructor(message, status = 400) {
673
+ super(message);
674
+ this.name = 'SyncRouteError';
675
+ this.status = status;
676
+ }
677
+ }
678
+ /** 同步路由错误统一出口:SyncRouteError 用其 status,其余 500(GitTransport 错误消息已脱敏) */
679
+ export function writeSyncRouteError(res, error) {
680
+ if (error instanceof SyncRouteError) {
681
+ writeJson(res, error.status, { error: error.message });
682
+ return;
683
+ }
684
+ const message = error instanceof Error ? error.message : String(error);
685
+ writeJson(res, 500, { error: message });
686
+ }
687
+ export function buildRestoreBody(body) {
688
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
689
+ return { ok: false, error: 'invalid JSON body' };
690
+ }
691
+ const record = body;
692
+ const snapshotId = record['snapshotId'];
693
+ if (typeof snapshotId !== 'string' || snapshotId === '') {
694
+ return { ok: false, error: 'snapshotId is required' };
695
+ }
696
+ if (snapshotId === '.' || snapshotId === '..' || snapshotId.includes('/') || snapshotId.includes('\\')) {
697
+ return { ok: false, error: 'snapshotId 非法(禁止路径分隔符)/ invalid snapshotId' };
698
+ }
699
+ return { ok: true, value: { snapshotId, dryRun: record['dryRun'] === true } };
700
+ }
701
+ /**
702
+ * 按计划执行恢复动作(纯执行器;逐项 try/catch 不拖垮其余),
703
+ * 返回与 CLI 一致的诚实报告。顺序 = 计划顺序(整文件 → 插件 → file 补偿)。
704
+ */
705
+ export async function executeRestorePlan(plan, exec) {
706
+ const report = {
707
+ snapshotId: plan.snapshotId,
708
+ restored: [],
709
+ removedPlugins: [],
710
+ manualHints: [],
711
+ failed: [],
712
+ skipped: [],
713
+ };
714
+ for (const action of plan.actions) {
715
+ try {
716
+ switch (action.kind) {
717
+ case 'hostFileRestore':
718
+ case 'fileRestore': {
719
+ if (action.target === undefined || action.blobPath === undefined) {
720
+ throw new Error('恢复动作缺少 target/blobPath');
721
+ }
722
+ if (await exec.existsHome(action.target))
723
+ await exec.savePreRestore(action.target);
724
+ await exec.writeHome(action.target, await exec.readBlob(action.blobPath));
725
+ report.restored.push(action.target);
726
+ break;
727
+ }
728
+ case 'hostFileRemove':
729
+ case 'fileRemove': {
730
+ if (action.target === undefined)
731
+ throw new Error('恢复动作缺少 target');
732
+ if (await exec.existsHome(action.target)) {
733
+ await exec.savePreRestore(action.target);
734
+ await exec.removeHome(action.target);
735
+ }
736
+ report.restored.push(action.target);
737
+ break;
738
+ }
739
+ case 'pluginRemove': {
740
+ if (action.pluginName === undefined)
741
+ throw new Error('恢复动作缺少插件名');
742
+ const result = await exec.uninstallPlugin(action.pluginName);
743
+ if (result.ok) {
744
+ report.removedPlugins.push(action.pluginName);
745
+ }
746
+ else {
747
+ report.failed.push({ item: `plugin:${action.pluginName}`, reason: result.message ?? '卸载失败' });
748
+ }
749
+ break;
750
+ }
751
+ case 'credentialHint':
752
+ report.manualHints.push(action.manualHint ?? action.description);
753
+ break;
754
+ case 'skip':
755
+ report.skipped.push(action.description);
756
+ break;
757
+ default:
758
+ report.skipped.push(`未知动作 ${String(action.kind)}: ${action.description}`);
759
+ }
760
+ }
761
+ catch (err) {
762
+ report.failed.push({
763
+ item: action.target ?? action.pluginName ?? action.description,
764
+ reason: err instanceof Error ? err.message : String(err),
765
+ });
766
+ }
767
+ }
768
+ return report;
769
+ }
770
+ /** 宿主 restore 执行器装配:ctx.fs(home-relative)+ 快照目录(node fs)+ runDshPlugin。 */
771
+ function makeRestoreExecutor(snapshotDir, host, profile) {
772
+ const profileDir = resolveProfileDir(host.homeDir, profile);
773
+ let seq = 0;
774
+ return {
775
+ readBlob: async (blobPath) => {
776
+ const target = resolve(snapshotDir, blobPath);
777
+ if (!isSameOrChild(target, snapshotDir))
778
+ throw new Error(`blob 路径越界: ${blobPath}`);
779
+ return fs.readFile(target);
780
+ },
781
+ savePreRestore: async (relPath) => {
782
+ const data = await host.fs.readFile(relPath);
783
+ seq += 1;
784
+ const safe = relPath.replace(/[\\/:*?"<>|]/g, '_');
785
+ await fs.mkdir(join(snapshotDir, 'pre-restore'), { recursive: true });
786
+ await fs.writeFile(join(snapshotDir, 'pre-restore', `${String(seq).padStart(4, '0')}-${safe}`), data);
787
+ },
788
+ existsHome: (relPath) => host.fs.exists(relPath),
789
+ writeHome: (relPath, data) => host.fs.writeFile(relPath, data),
790
+ removeHome: (relPath) => host.fs.remove(relPath),
791
+ uninstallPlugin: async (name) => {
792
+ const result = await runDshPlugin(profileDir, profile, ['remove', name]);
793
+ if (result.exitCode === 0)
794
+ return { ok: true };
795
+ const output = `${result.stderr}\n${result.stdout}`.trim();
796
+ const tail = output.split('\n').slice(-8).join('\n') || '无输出';
797
+ return { ok: false, message: `dsh plugin remove ${name} 失败(exit ${String(result.exitCode)}):${tail}` };
798
+ },
799
+ };
800
+ }
646
801
  /** Build the /api/dsh-config-manager route family. */
647
802
  function makeRoutes(deps) {
648
- const { host, adapters, exportsDir, tmpDir, snapshotsDir } = deps;
803
+ const { host, adapters, exportsDir, tmpDir, snapshotsDir, runs, syncDir, credentials } = deps;
649
804
  const roots = [exportsDir, tmpDir];
650
805
  const makeImporter = () => new Importer({
651
806
  ctx: host,
@@ -666,6 +821,54 @@ function makeRoutes(deps) {
666
821
  }
667
822
  return true;
668
823
  };
824
+ // ------------------------------------------------- sync 路由装配(m-sync-ui)
825
+ // 请求级装配:每次 push/pull 从请求体取 repoUrl/gitBin,token 非空先写入 DSH
826
+ // credentials(只存值不落盘同步文件/日志),git 网络操作时经 resolve 现取 ——
827
+ // 与 GitTransport「token 只从注入 provider 读取」的安全契约完全对齐。
828
+ /** 解析同步请求体(repoUrl 必填;token 非空则先写入 DSH credentials)。 */
829
+ const prepareSync = async (body) => {
830
+ const repoUrl = typeof body['repoUrl'] === 'string' ? body['repoUrl'].trim() : '';
831
+ if (repoUrl === '')
832
+ throw new SyncRouteError('repoUrl is required');
833
+ const urlError = validateRepoUrl(repoUrl);
834
+ if (urlError !== null)
835
+ throw new SyncRouteError(urlError);
836
+ const gitBin = typeof body['gitBin'] === 'string' && body['gitBin'] !== '' ? body['gitBin'] : undefined;
837
+ const token = typeof body['token'] === 'string' && body['token'] !== '' ? body['token'] : undefined;
838
+ if (token !== undefined) {
839
+ try {
840
+ await credentials.set(credentialRef(SYNC_CREDENTIAL_REF), token);
841
+ }
842
+ catch (error) {
843
+ const reason = error instanceof Error ? error.message : String(error);
844
+ throw new SyncRouteError(`token 写入 DSH credentials 失败:${reason}(请在 DSH 凭据管理里配置 ${SYNC_CREDENTIAL_REF} 后重试)`);
845
+ }
846
+ }
847
+ return { repoUrl, gitBin };
848
+ };
849
+ /** 构造 SyncEngine(Git 私有仓库通道 + 散文件快照目录 + pull 用 Importer)。 */
850
+ const makeSyncEngine = (repoUrl, gitBin) => {
851
+ const transport = new GitTransport({
852
+ repoUrl,
853
+ workDir: join(syncDir, 'work'),
854
+ credentials: {
855
+ getToken: async () => {
856
+ const resolved = await credentials.resolve(credentialRef(SYNC_CREDENTIAL_REF));
857
+ return resolved?.value ?? '';
858
+ },
859
+ },
860
+ gitBin,
861
+ });
862
+ return new SyncEngine({
863
+ ctx: host,
864
+ transport,
865
+ stateDir: syncDir,
866
+ adapters,
867
+ importer: makeImporter(),
868
+ localSnapshotsDir: join(syncDir, 'snapshots'),
869
+ zipDir: tmpDir,
870
+ });
871
+ };
669
872
  return [
670
873
  // ------------------------------------------------------------- status
671
874
  {
@@ -702,19 +905,43 @@ function makeRoutes(deps) {
702
905
  // Encryption password is in-memory only (never persisted / logged).
703
906
  const password = typeof body['password'] === 'string' && body['password'] !== '' ? body['password'] : undefined;
704
907
  const outPath = join(exportsDir, `dsh-config-${dateStamp()}-${randomBytes(3).toString('hex')}.zip`);
908
+ // m1:执行开始注册 run(同 kind 已有进行中任务 → 409 拒绝,防止重复导出)
909
+ let run;
910
+ try {
911
+ run = runs.register('export');
912
+ }
913
+ catch (error) {
914
+ writeJson(res, 409, { error: error instanceof Error ? error.message : String(error) });
915
+ return;
916
+ }
917
+ const runId = run.runId;
705
918
  try {
706
919
  const exporter = new Exporter({
707
920
  ctx: host,
708
921
  adapters,
709
922
  encryption: includeSecrets && password !== undefined ? createEncryptionProvider(password) : null,
710
923
  exporterVersion: PLUGIN_VERSION,
924
+ // m1 埋点:每导出一个分区实时更新 run 状态(/progress 轮询可见)
925
+ onSection: (info) => {
926
+ runs.update(runId, {
927
+ section: info.section,
928
+ sectionTotal: info.total,
929
+ item: info.index,
930
+ itemTotal: info.total,
931
+ detail: info.section,
932
+ });
933
+ },
711
934
  });
712
935
  const result = await withTimeout(exporter.export({ includeSecrets, only, outPath }), ROUTE_TIMEOUT_MS, '导出超时(5 分钟):导出过程未完成,请重试;若反复超时请检查 profile 依赖状态');
713
- writeJson(res, 200, { zipPath: result.zipPath, manifest: result.manifest, report: result.report });
936
+ // 结束写结果:完成结果落账(供 /progress 查询与刷新恢复后下载)
937
+ runs.finish(runId, { zipPath: result.zipPath, manifest: result.manifest, report: result.report });
938
+ writeJson(res, 200, { zipPath: result.zipPath, manifest: result.manifest, report: result.report, runId });
714
939
  }
715
940
  catch (error) {
716
- host.log.error('导出失败', { error: error instanceof Error ? error.message : String(error) });
717
- writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
941
+ const message = error instanceof Error ? error.message : String(error);
942
+ runs.fail(runId, message);
943
+ host.log.error('导出失败', { error: message });
944
+ writeJson(res, 500, { error: message, runId });
718
945
  }
719
946
  },
720
947
  },
@@ -840,6 +1067,39 @@ function makeRoutes(deps) {
840
1067
  }
841
1068
  },
842
1069
  },
1070
+ // ------------------------------------------------------------ progress
1071
+ // m1:查询单个 run 的实时状态(轮询 / 刷新恢复用;runId 不可猜,走 loopback-only 守卫)
1072
+ {
1073
+ kind: 'exact',
1074
+ path: API.progress,
1075
+ handler: async (req, res) => {
1076
+ if (!guard(req, res, 'GET'))
1077
+ return;
1078
+ const url = new URL(req.url ?? '/', 'http://localhost');
1079
+ const runId = queryParam(url, 'runId');
1080
+ if (runId === undefined || runId === '') {
1081
+ writeJson(res, 400, { error: 'runId query parameter is required' });
1082
+ return;
1083
+ }
1084
+ const state = runs.get(runId);
1085
+ if (state === undefined) {
1086
+ writeJson(res, 404, { error: `run ${runId} not found(可能已过保留期被清理)` });
1087
+ return;
1088
+ }
1089
+ writeJson(res, 200, state);
1090
+ },
1091
+ },
1092
+ // ----------------------------------------------------------------- runs
1093
+ // m1:列出当前活跃(running)的 run(刷新恢复时重新订阅进度用)
1094
+ {
1095
+ kind: 'exact',
1096
+ path: API.runs,
1097
+ handler: async (req, res) => {
1098
+ if (!guard(req, res, 'GET'))
1099
+ return;
1100
+ writeJson(res, 200, runs.listActive());
1101
+ },
1102
+ },
843
1103
  // ------------------------------------------------------------- execute
844
1104
  {
845
1105
  kind: 'exact',
@@ -864,6 +1124,16 @@ function makeRoutes(deps) {
864
1124
  const decryptPassword = typeof opts['decryptPassword'] === 'string' && opts['decryptPassword'] !== ''
865
1125
  ? opts['decryptPassword']
866
1126
  : undefined;
1127
+ // m1:执行开始注册 run(同 kind 已有进行中任务 → 409 拒绝,防止重复导入)
1128
+ let run;
1129
+ try {
1130
+ run = runs.register('import');
1131
+ }
1132
+ catch (error) {
1133
+ writeJson(res, 409, { error: error instanceof Error ? error.message : String(error) });
1134
+ return;
1135
+ }
1136
+ const runId = run.runId;
867
1137
  try {
868
1138
  const decryptedCredentials = await tryDecryptCredentials(zipPath, decryptPassword);
869
1139
  const result = await makeImporter().executeImportPlan(zipPath, plan, {
@@ -873,15 +1143,168 @@ function makeRoutes(deps) {
873
1143
  : {},
874
1144
  rollbackOnError: opts['rollbackOnError'] === true,
875
1145
  decryptedCredentials,
1146
+ // m1 埋点:每完成一个计划项实时更新 run 状态(/progress 轮询可见)
1147
+ onItem: (info) => {
1148
+ runs.update(runId, {
1149
+ section: info.adapter,
1150
+ item: info.index,
1151
+ itemTotal: info.total,
1152
+ detail: info.detail ?? info.adapter,
1153
+ });
1154
+ },
876
1155
  });
877
- writeJson(res, 200, result);
1156
+ // 结束写结果:导入结果落账(供 /progress 查询与刷新恢复)
1157
+ runs.finish(runId, result);
1158
+ writeJson(res, 200, { ...result, runId });
1159
+ }
1160
+ catch (error) {
1161
+ const message = error instanceof Error ? error.message : String(error);
1162
+ runs.fail(runId, message);
1163
+ host.log.error('导入执行失败', { error: message });
1164
+ writeJson(res, 400, { error: message, runId });
1165
+ }
1166
+ },
1167
+ },
1168
+ // ---------------------------------------------------------- snapshots
1169
+ // M4:列出快照元信息(id/createdAt/sourceZip/status/计数,createdAt 倒序)
1170
+ {
1171
+ kind: 'exact',
1172
+ path: API.snapshots,
1173
+ handler: async (req, res) => {
1174
+ if (!guard(req, res, 'GET'))
1175
+ return;
1176
+ try {
1177
+ writeJson(res, 200, { snapshots: await listSnapshots(snapshotsDir) });
1178
+ }
1179
+ catch (error) {
1180
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
1181
+ }
1182
+ },
1183
+ },
1184
+ // ------------------------------------------------------------ restore
1185
+ // M4:快照恢复。dryRun=true 只返回动作计划(planRestore,零写入);
1186
+ // 真实执行 = 计划 → 宿主执行器(ctx.fs 整文件/文件还原 + runDshPlugin 卸载插件)
1187
+ // → 与 CLI 一致的诚实报告 { restored/removedPlugins/manualHints/failed/skipped }。
1188
+ {
1189
+ kind: 'exact',
1190
+ path: API.restore,
1191
+ handler: async (req, res) => {
1192
+ if (!guard(req, res, 'POST'))
1193
+ return;
1194
+ const body = await readJsonBody(req);
1195
+ const parsed = buildRestoreBody(body);
1196
+ if (!parsed.ok) {
1197
+ writeJson(res, 400, { error: parsed.error });
1198
+ return;
1199
+ }
1200
+ const { snapshotId, dryRun } = parsed.value;
1201
+ const snapshotDir = join(snapshotsDir, snapshotId);
1202
+ const restoreOpts = {
1203
+ snapshotDir,
1204
+ homeDir: host.homeDir,
1205
+ profile: host.profile,
1206
+ settingsPath: undefined,
1207
+ };
1208
+ try {
1209
+ if (dryRun) {
1210
+ writeJson(res, 200, { dryRun: true, plan: await planRestore(restoreOpts) });
1211
+ return;
1212
+ }
1213
+ const plan = await planRestore(restoreOpts);
1214
+ const report = await executeRestorePlan(plan, makeRestoreExecutor(snapshotDir, host, host.profile));
1215
+ writeJson(res, 200, { dryRun: false, report });
878
1216
  }
879
1217
  catch (error) {
880
- host.log.error('导入执行失败', { error: error instanceof Error ? error.message : String(error) });
881
1218
  writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
882
1219
  }
883
1220
  },
884
1221
  },
1222
+ // ------------------------------------------------------ sync/status
1223
+ // m-sync-ui:同步状态(仓库配置 / 凭据状态 / 上次同步 / 分区数)。只读,无 secret 值。
1224
+ {
1225
+ kind: 'exact',
1226
+ path: API.syncStatus,
1227
+ handler: async (req, res) => {
1228
+ if (!guard(req, res, 'GET'))
1229
+ return;
1230
+ try {
1231
+ const [cfg, state, cred] = await Promise.all([
1232
+ readSyncConfig(syncDir),
1233
+ loadSyncState(syncDir),
1234
+ credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
1235
+ ]);
1236
+ writeJson(res, 200, {
1237
+ ok: true,
1238
+ configured: cfg !== null,
1239
+ repoUrl: cfg?.repoUrl,
1240
+ gitBin: cfg?.gitBin,
1241
+ credentialConfigured: cred.configured,
1242
+ credentialWritable: cred.writable === true,
1243
+ lastSyncAt: state.lastSyncAt === '' ? undefined : state.lastSyncAt,
1244
+ sectionCount: Object.keys(state.sections).length,
1245
+ transport: state.transport,
1246
+ });
1247
+ }
1248
+ catch (error) {
1249
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
1250
+ }
1251
+ },
1252
+ },
1253
+ // ------------------------------------------------------ sync/push
1254
+ // m-sync-ui:推送(导出 portable 分区 → 提交私有仓库 → 更新 sync-state)。
1255
+ // token 可选:非空先写入 DSH credentials;成功则记忆仓库配置(回填表单用)。
1256
+ {
1257
+ kind: 'exact',
1258
+ path: API.syncPush,
1259
+ handler: async (req, res) => {
1260
+ if (!guard(req, res, 'POST'))
1261
+ return;
1262
+ const body = await readJsonBody(req);
1263
+ if (body === undefined) {
1264
+ writeJson(res, 400, { error: 'invalid JSON body' });
1265
+ return;
1266
+ }
1267
+ try {
1268
+ const { repoUrl, gitBin } = await prepareSync(body);
1269
+ const engine = makeSyncEngine(repoUrl, gitBin);
1270
+ const snapshotId = typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined;
1271
+ const report = await withTimeout(engine.push(snapshotId === undefined ? {} : { snapshotId }), ROUTE_TIMEOUT_MS, '同步推送超时(5 分钟):请检查网络与仓库可达性后重试');
1272
+ await writeSyncConfig(syncDir, { repoUrl, gitBin });
1273
+ writeJson(res, 200, report);
1274
+ }
1275
+ catch (error) {
1276
+ writeSyncRouteError(res, error);
1277
+ }
1278
+ },
1279
+ },
1280
+ // ------------------------------------------------------ sync/pull
1281
+ // m-sync-ui:拉取差异预览(只读:list/download → 转临时 ZIP → Importer 分析出计划摘要)。
1282
+ // 绝不直接写配置、绝不执行导入(executeImportPlan 由上层按用户确认驱动)。
1283
+ {
1284
+ kind: 'exact',
1285
+ path: API.syncPull,
1286
+ handler: async (req, res) => {
1287
+ if (!guard(req, res, 'POST'))
1288
+ return;
1289
+ const body = await readJsonBody(req);
1290
+ if (body === undefined) {
1291
+ writeJson(res, 400, { error: 'invalid JSON body' });
1292
+ return;
1293
+ }
1294
+ try {
1295
+ const { repoUrl, gitBin } = await prepareSync(body);
1296
+ const engine = makeSyncEngine(repoUrl, gitBin);
1297
+ const strategy = body['strategy'] === 'replace' || body['strategy'] === 'skipExisting' ? body['strategy'] : 'merge';
1298
+ const snapshotId = typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined;
1299
+ const report = await withTimeout(engine.pull({ strategy, ...(snapshotId === undefined ? {} : { snapshotId }) }), ROUTE_TIMEOUT_MS, '同步拉取超时(5 分钟):请检查网络与仓库可达性后重试');
1300
+ await writeSyncConfig(syncDir, { repoUrl, gitBin });
1301
+ writeJson(res, 200, report);
1302
+ }
1303
+ catch (error) {
1304
+ writeSyncRouteError(res, error);
1305
+ }
1306
+ },
1307
+ },
885
1308
  ];
886
1309
  }
887
1310
  /* ------------------------------------------------------------------ apply */
@@ -901,9 +1324,11 @@ export function apply(ctx, config) {
901
1324
  const exportsDir = join(dataDir, 'exports');
902
1325
  const tmpDir = join(dataDir, 'tmp');
903
1326
  const snapshotsDir = join(dataDir, 'snapshots');
1327
+ const syncDir = join(dataDir, 'sync');
904
1328
  mkdirSync(exportsDir, { recursive: true });
905
1329
  mkdirSync(tmpDir, { recursive: true });
906
1330
  mkdirSync(snapshotsDir, { recursive: true });
1331
+ mkdirSync(syncDir, { recursive: true });
907
1332
  const host = new ConfigManagerHostContext(ctx, homeDir, resolveProfileName(config));
908
1333
  const adapters = createAdapters({
909
1334
  // Namespace list = everything the settings service has registered.
@@ -915,7 +1340,16 @@ export function apply(ctx, config) {
915
1340
  dshVersion: host.dshVersion,
916
1341
  adapters: adapters.map((a) => a.id),
917
1342
  });
918
- const routes = makeRoutes({ host, adapters, exportsDir, tmpDir, snapshotsDir });
1343
+ const routes = makeRoutes({
1344
+ host,
1345
+ adapters,
1346
+ exportsDir,
1347
+ tmpDir,
1348
+ snapshotsDir,
1349
+ runs: new RunRegistry(),
1350
+ syncDir,
1351
+ credentials: ctx.credentials,
1352
+ });
919
1353
  const webServer = readService(ctx, 'webServer');
920
1354
  if (webServer === undefined) {
921
1355
  host.log.warn('webServer 服务不可用:跳过 /api/dsh-config-manager 路由注册(引擎能力仍可用)');