dsh-config-manager 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/README.zh-CN.md +30 -0
- package/lib/adapters/test-helpers.d.ts +4 -2
- package/lib/adapters/test-helpers.js +11 -3
- package/lib/adapters/test-helpers.js.map +1 -1
- package/lib/cli/index.d.ts +44 -0
- package/lib/cli/index.js +230 -0
- package/lib/cli/index.js.map +1 -0
- package/lib/client.d.ts +260 -5
- package/lib/client.js +2519 -820
- package/lib/client.js.map +1 -1
- package/lib/core/analyzer.d.ts +18 -1
- package/lib/core/analyzer.js +29 -0
- package/lib/core/analyzer.js.map +1 -1
- package/lib/core/backup.d.ts +3 -1
- package/lib/core/backup.js +70 -0
- package/lib/core/backup.js.map +1 -1
- package/lib/core/exporter.d.ts +11 -0
- package/lib/core/exporter.js +11 -0
- package/lib/core/exporter.js.map +1 -1
- package/lib/core/importer.d.ts +3 -0
- package/lib/core/importer.js +1 -0
- package/lib/core/importer.js.map +1 -1
- package/lib/core/index.d.ts +1 -0
- package/lib/core/index.js +1 -0
- package/lib/core/index.js.map +1 -1
- package/lib/core/restore.d.ts +92 -0
- package/lib/core/restore.js +505 -0
- package/lib/core/restore.js.map +1 -0
- package/lib/core/run-registry.d.ts +65 -0
- package/lib/core/run-registry.js +120 -0
- package/lib/core/run-registry.js.map +1 -0
- package/lib/core/types.d.ts +20 -0
- package/lib/core/types.js.map +1 -1
- package/lib/index.d.ts +45 -0
- package/lib/index.js +442 -8
- package/lib/index.js.map +1 -1
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +47 -0
- package/lib/sync/fs.js.map +1 -0
- package/lib/sync/git/git-transport.d.ts +87 -0
- package/lib/sync/git/git-transport.js +333 -0
- package/lib/sync/git/git-transport.js.map +1 -0
- package/lib/sync/layout.d.ts +29 -0
- package/lib/sync/layout.js +166 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/sync-config.d.ts +16 -0
- package/lib/sync/sync-config.js +67 -0
- package/lib/sync/sync-config.js.map +1 -0
- package/lib/sync/sync-engine.d.ts +96 -0
- package/lib/sync/sync-engine.js +236 -0
- package/lib/sync/sync-engine.js.map +1 -0
- package/lib/sync/sync-state.d.ts +31 -0
- package/lib/sync/sync-state.js +103 -0
- package/lib/sync/sync-state.js.map +1 -0
- package/lib/sync/transport.d.ts +50 -0
- package/lib/sync/transport.js +22 -0
- package/lib/sync/transport.js.map +1 -0
- package/package.json +4 -1
- package/src/adapters/test-helpers.ts +10 -3
- package/src/cli/index.ts +278 -0
- package/src/client/ConfigManagerSection.tsx +49 -14
- package/src/client/api.ts +53 -5
- package/src/client/client-types.ts +0 -6
- package/src/client/common/ProgressBar.tsx +31 -19
- package/src/client/common/progress-view-edge.test.ts +60 -0
- package/src/client/common/progress-view.test.ts +79 -0
- package/src/client/common/progress-view.ts +94 -0
- package/src/client/config-manager.module.css +99 -20
- package/src/client/export/ExportView.tsx +63 -49
- package/src/client/import/ImportWizardView.tsx +107 -66
- package/src/client/index.ts +22 -0
- package/src/client/locales.ts +53 -0
- package/src/client/run-store.test.ts +500 -0
- package/src/client/run-store.ts +772 -0
- package/src/client/snapshots/SnapshotsPanel.tsx +240 -0
- package/src/client/sync/SyncSettingsView.tsx +261 -0
- package/src/client/sync/sync-api.test.ts +117 -0
- package/src/client/sync/sync-api.ts +132 -0
- package/src/client/sync/sync-locales.ts +77 -0
- package/src/client/sync/sync-view.test.ts +211 -0
- package/src/client/sync/sync-view.ts +202 -0
- package/src/core/analyzer.ts +50 -1
- package/src/core/backup.ts +78 -2
- package/src/core/exporter.ts +21 -0
- package/src/core/importer.ts +4 -0
- package/src/core/index.ts +5 -0
- package/src/core/restore.ts +587 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +139 -0
- package/src/core/run-registry.ts +166 -0
- package/src/core/smoke.test.ts +5 -0
- package/src/core/types.ts +22 -0
- package/src/index.ts +476 -8
- package/src/security/security.test.ts +5 -0
- package/src/sync/fs.ts +46 -0
- package/src/sync/git/git-transport.test.ts +456 -0
- package/src/sync/git/git-transport.ts +390 -0
- package/src/sync/layout.test.ts +226 -0
- package/src/sync/layout.ts +183 -0
- package/src/sync/sync-config.ts +72 -0
- package/src/sync/sync-engine.test.ts +299 -0
- package/src/sync/sync-engine.ts +318 -0
- package/src/sync/sync-state.test.ts +118 -0
- package/src/sync/sync-state.ts +123 -0
- package/src/sync/transport.test.ts +110 -0
- package/src/sync/transport.ts +73 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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({
|
|
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 路由注册(引擎能力仍可用)');
|