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/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.
|
|
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
|
-
|
|
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
|
-
|
|
794
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
+
}
|