@gehennawu/dsh-service 2.0.6 → 2.0.8

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.
@@ -19,6 +19,20 @@ const MAX_META_BYTES = 4096
19
19
  // semver 形、长度封顶、且**必须含数字**——少了最后一条,未解析出 DSH 包的宿主
20
20
  // 写下的哨兵值 `unknown` 会被当成真版本显示出来。
21
21
  const META_VERSION_RE = /^(?=.*[0-9])[0-9A-Za-z][0-9A-Za-z.-]{0,63}$/
22
+ // 来源平台/架构:同样是**纯展示**信息,取值只接受 Node 平台串/架构串的字符集。
23
+ // 它不参与任何裁决,所以宽松解析(非法即 null)。
24
+ const META_PLATFORM_RE = /^[a-z][a-z0-9_-]{0,15}$/
25
+ // 跨平台提示:只读告警,绝不让一份可恢复的归档因为「目标平台会麻烦」被拒。
26
+ const MAX_NOTICES = 8
27
+ // Windows 传统 MAX_PATH(260)留 1 位给结尾 NUL 的保守口径。
28
+ const WINDOWS_PATH_LIMIT = 259
29
+ const CASE_INSENSITIVE_PLATFORMS = Object.freeze(['win32', 'darwin'])
30
+ // 目标 profile 名与备份侧同名规则同口径:单一路径段、长度封顶;`.`/`..` 另判。
31
+ const PROFILE_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/
32
+ const MAX_PROFILE_MAPPINGS = 32
33
+ // Windows 上「文件被占用」的 errno 集合:rename/rm 撞到它们时给可操作错误码。
34
+ // 其他平台同样的 errno 更可能是权限问题,仍走通用 restore-failed。
35
+ const WINDOWS_LOCK_ERROR_CODES = Object.freeze(['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'])
22
36
  const PLAN_TTL_MS = 5 * 60 * 1000
23
37
  const MAX_COMPRESSED_BYTES = 512 * 1024 * 1024
24
38
  const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024
@@ -116,7 +130,92 @@ function parseBackupMetadata(data) {
116
130
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
117
131
  const version = typeof parsed.dshVersion === 'string' && META_VERSION_RE.test(parsed.dshVersion) ? parsed.dshVersion : null
118
132
  const createdAt = typeof parsed.createdAt === 'string' && !Number.isNaN(Date.parse(parsed.createdAt)) ? parsed.createdAt : null
119
- return { dshVersion: version, createdAt }
133
+ // 来源平台/架构(本版新增字段):缺失或非法一律 null,界面对「早于本版的归档」不显示来源。
134
+ const platform = typeof parsed.platform === 'string' && META_PLATFORM_RE.test(parsed.platform) ? parsed.platform : null
135
+ const arch = typeof parsed.arch === 'string' && META_PLATFORM_RE.test(parsed.arch) ? parsed.arch : null
136
+ return { dshVersion: version, createdAt, platform, arch }
137
+ }
138
+
139
+ // 跨设备/跨平台提示(纯函数,便于直接断言):输入是**目标机上的落盘相对路径**,
140
+ // 输出是稳定 code 的只读提示。三条口径:
141
+ // ① 来源平台已知且与目标不同 → 提示(信息级);
142
+ // ② 目标卷不区分大小写(win32/darwin 默认)时,仅大小写不同的条目会折叠到同一路径;
143
+ // ③ win32 上超长路径(经典 260 上限)会在写入时才炸,提前算出最长值告知。
144
+ // 任何一条都不改变 validForRestore——恢复能否进行仍只由完整性裁决。
145
+ function collectPlatformNotices(paths, options) {
146
+ const { platform, dshHome, sourcePlatform = null } = options
147
+ const notices = []
148
+ if (typeof sourcePlatform === 'string' && sourcePlatform !== '' && sourcePlatform !== platform) {
149
+ notices.push({ code: 'source-platform-differs', detail: sourcePlatform })
150
+ }
151
+ if (CASE_INSENSITIVE_PLATFORMS.includes(platform)) {
152
+ const folded = new Map()
153
+ for (const relative of paths) {
154
+ const key = relative.toLowerCase()
155
+ const existing = folded.get(key)
156
+ if (existing !== undefined && existing !== relative) {
157
+ notices.push({ code: 'target-case-collision', detail: relative.slice(0, 200) })
158
+ break
159
+ }
160
+ folded.set(key, relative)
161
+ }
162
+ }
163
+ if (platform === 'win32' && typeof dshHome === 'string') {
164
+ let longest = 0
165
+ for (const relative of paths) longest = Math.max(longest, dshHome.length + 1 + relative.length)
166
+ if (longest > WINDOWS_PATH_LIMIT) notices.push({ code: 'target-path-length', detail: String(longest) })
167
+ }
168
+ return notices.slice(0, MAX_NOTICES)
169
+ }
170
+
171
+ // 恢复提交阶段的失败归因:Windows 上 rename/rm 撞到占用类 errno 时给专属码,
172
+ // 让客户端能说「关掉占用方再试」,而不是笼统的 restore-failed。
173
+ function restoreFailureCode(error, platform) {
174
+ if (platform === 'win32' && WINDOWS_LOCK_ERROR_CODES.includes(error?.code)) return 'restore-files-locked'
175
+ return 'restore-failed'
176
+ }
177
+
178
+ function isSafeProfileName(value) {
179
+ return typeof value === 'string' && PROFILE_NAME_RE.test(value) && value !== '.' && value !== '..'
180
+ }
181
+
182
+ // 把归档里的 profile 路径按映射改写:`profiles/<source>/<file>` → `profiles/<target>/<file>`。
183
+ // 只认三段式白名单路径(校验阶段已保证),其余路径原样返回。
184
+ function remapProfilePath(path, mapping) {
185
+ if (mapping === undefined || mapping.size === 0) return path
186
+ const parts = path.split('/')
187
+ if (parts.length !== 3 || parts[0] !== 'profiles') return path
188
+ const target = mapping.get(parts[1])
189
+ return target === undefined || target === parts[1] ? path : `profiles/${target}/${parts[2]}`
190
+ }
191
+
192
+ // 请求映射的归一与校验。返回 `{ mapping }` 或 `{ error }`(稳定码,调用方转 domainError)。
193
+ // 省略请求即「逐条同名 + 覆盖 manifest」,与本轮之前的行为逐字一致。
194
+ function normalizeProfileMapping(requested, archived, localProfiles) {
195
+ const archivedNames = [...new Set(archived)].sort()
196
+ if (requested === undefined || requested === null) {
197
+ return { mapping: archivedNames.map((name) => ({ source: name, target: name, manifest: true })) }
198
+ }
199
+ if (!Array.isArray(requested) || requested.length > MAX_PROFILE_MAPPINGS) return { error: 'restore-mapping-invalid' }
200
+ const local = new Set(Array.isArray(localProfiles) ? localProfiles.filter(isSafeProfileName) : [])
201
+ const archivedSet = new Set(archivedNames)
202
+ const seenSources = new Set()
203
+ const seenTargets = new Set()
204
+ const mapping = []
205
+ for (const entry of requested) {
206
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return { error: 'restore-mapping-invalid' }
207
+ const source = entry.source
208
+ const target = entry.target
209
+ if (!archivedSet.has(source) || !isSafeProfileName(target)) return { error: 'restore-mapping-invalid' }
210
+ // 目标名只允许宿主清单内的名字,或与源同名(保持「同名新建」的既有语义)。
211
+ if (target !== source && !local.has(target)) return { error: 'restore-mapping-invalid' }
212
+ if (entry.manifest !== undefined && typeof entry.manifest !== 'boolean') return { error: 'restore-mapping-invalid' }
213
+ if (seenSources.has(source) || seenTargets.has(target)) return { error: 'restore-mapping-invalid' }
214
+ seenSources.add(source)
215
+ seenTargets.add(target)
216
+ mapping.push({ source, target, manifest: entry.manifest === undefined ? source === target : entry.manifest })
217
+ }
218
+ return { mapping }
120
219
  }
121
220
 
122
221
  function validateEntry(path, type, data, state) {
@@ -248,6 +347,8 @@ function parseTar(expanded, options = {}) {
248
347
  validateEntry(path, type, payload, state)
249
348
  if (options.collectEntries === true) entries.push({ path, type, data: type === 'file' ? Buffer.from(payload) : undefined })
250
349
  else if (options.digestEntries === true) entries.push(type === 'file' ? { path, type, size: headerSize, sha256: createHash('sha256').update(payload).digest('hex') } : { path, type })
350
+ // 只取路径的轻量模式:跨平台提示要逐条目算目标路径,但不需要 payload。
351
+ else if (options.collectPaths === true) entries.push({ path, type })
251
352
  else entries.push(null)
252
353
  }
253
354
 
@@ -281,6 +382,10 @@ async function inspectArchive(source, options = {}) {
281
382
  // 界面优先显示后者;前者是廉价但可能与内容不符的镜像。
282
383
  nameVersion: typeof source.nameVersion === 'string' ? source.nameVersion : null,
283
384
  metadata: null,
385
+ // 形状恒定:坏归档也给 null / 空数组,客户端不必按存在性写防御分支。
386
+ sourcePlatform: null,
387
+ sourceArch: null,
388
+ notices: [],
284
389
  sections: emptySections(),
285
390
  issues: [],
286
391
  issueCount: 0,
@@ -302,7 +407,7 @@ async function inspectArchive(source, options = {}) {
302
407
  try { expanded = await gunzipArchive(compressed, { maxOutputLength: MAX_EXPANDED_BYTES }) } catch (error) {
303
408
  throw domainError(error?.code === 'ERR_BUFFER_TOO_LARGE' ? 'backup-size-limit' : 'backup-gzip-invalid')
304
409
  }
305
- const parsed = parseTar(expanded, options)
410
+ const parsed = parseTar(expanded, { ...options, collectPaths: true })
306
411
  return {
307
412
  report: {
308
413
  ...base,
@@ -313,6 +418,15 @@ async function inspectArchive(source, options = {}) {
313
418
  // 元数据非法/缺席时退回文件名里的版本段,保证「只要有一处记了版本」就看得见。
314
419
  metadata: parsed.metadata ?? null,
315
420
  dshVersion: parsed.metadata?.dshVersion ?? (typeof source.nameVersion === 'string' ? source.nameVersion : null),
421
+ // 来源平台/架构只做展示与提示;旧归档缺字段即 null。
422
+ sourcePlatform: parsed.metadata?.platform ?? null,
423
+ sourceArch: parsed.metadata?.arch ?? null,
424
+ // 跨平台提示(只读,不影响 validForRestore)。计划阶段会按映射后的目标路径重算。
425
+ notices: collectPlatformNotices(parsed.entries.map((entry) => entry.path), {
426
+ platform: options.platform ?? process.platform,
427
+ dshHome: options.dshHome,
428
+ sourcePlatform: parsed.metadata?.platform ?? null,
429
+ }),
316
430
  sections: parsed.sections,
317
431
  },
318
432
  parsed,
@@ -448,6 +562,28 @@ async function pathExists(path) {
448
562
  try { await lstat(path); return true } catch (error) { if (error?.code === 'ENOENT') return false; throw error }
449
563
  }
450
564
 
565
+ // 目标机会话目录的**只读诊断**探测:建临时文件 → 改名 → 删除。失败最可能的原因是
566
+ // 目标平台上有进程占用着会话目录(Windows 上宿主自身/同步盘/杀毒都可能持句柄),
567
+ // 也可能是权限不足;两种情况都值得在确认前告诉用户。sessions/ 不存在时跳过探测
568
+ // ——恢复本来就会新建它,此时「不可写」无从谈起。探测文件在返回前一定清理干净,
569
+ // 否则会污染紧随其后计算的目标指纹。
570
+ async function probeSessionsWritable(dshHome) {
571
+ const dir = join(dshHome, 'sessions')
572
+ if (!(await pathExists(dir))) return null
573
+ const probe = join(dir, `.dsh-service-probe-${randomUUID()}`)
574
+ const renamed = `${probe}.renamed`
575
+ try {
576
+ await writeFile(probe, '', { mode: 0o600 })
577
+ await rename(probe, renamed)
578
+ await unlink(renamed)
579
+ return null
580
+ } catch (error) {
581
+ await rm(probe, { force: true }).catch(() => {})
582
+ await rm(renamed, { force: true }).catch(() => {})
583
+ return typeof error?.code === 'string' ? error.code : 'probe-failed'
584
+ }
585
+ }
586
+
451
587
  async function writeJournal(path, value) {
452
588
  const temporary = `${path}.${randomUUID()}.tmp`
453
589
  try {
@@ -493,6 +629,8 @@ function publicPlan(plan) {
493
629
  reportSummary: plan.reportSummary,
494
630
  targets: plan.targets,
495
631
  consequences: plan.consequences,
632
+ // 跨平台提示(计划阶段按映射后路径重算):与报告同名同形,客户端优先用计划里的。
633
+ notices: plan.notices ?? [],
496
634
  previousInstanceId: plan.previousInstanceId,
497
635
  runtime: plan.runtime,
498
636
  }
@@ -508,6 +646,12 @@ export function createBackupIntegrity(options) {
508
646
  previousInstanceId,
509
647
  scheduleRestart = () => {},
510
648
  now = () => Date.now(),
649
+ // 目标平台:必须是「恢复实际发生的那台机器」的事实,默认取宿主进程平台。
650
+ // 只驱动只读提示与错误措辞,绝不参与归档裁决。
651
+ platform = process.platform,
652
+ // 目标机可选的 profile 名清单(宿主侧 readdir 结果);映射的目标必须落在其中
653
+ // 或与源同名(同名新建是既有语义)。
654
+ listProfiles = async () => [],
511
655
  } = options
512
656
  const plans = new Map()
513
657
  let recoveryError
@@ -545,14 +689,14 @@ export function createBackupIntegrity(options) {
545
689
  await prunePlans()
546
690
  const source = await resolveSource(id)
547
691
  if (source === undefined) return undefined
548
- return (await inspectArchive(source)).report
692
+ return (await inspectArchive(source, { platform, dshHome })).report
549
693
  }
550
694
 
551
695
  // tar 报「读取期间有变化」时的完整性兜底:解析归档并把它逐条目核对回暂存树。
552
696
  // 只有归档是暂存树的完整精确副本时才返回 true;任何缺失、截断或内容差异都判为不可发布。
553
697
  async function verifyArchivedTree(source, root) {
554
698
  await ensureRecovered()
555
- const inspected = await inspectArchive(source, { digestEntries: true })
699
+ const inspected = await inspectArchive(source, { digestEntries: true, platform, dshHome })
556
700
  if (!inspected.report.validForRestore || inspected.parsed === null) {
557
701
  const issue = inspected.report.issues?.[0]?.code
558
702
  throw domainError(issue || 'backup-archive-invalid')
@@ -561,7 +705,7 @@ export function createBackupIntegrity(options) {
561
705
  return true
562
706
  }
563
707
 
564
- async function prepareRestore(id) {
708
+ async function prepareRestore(id, requestedProfiles) {
565
709
  return withLock(async () => {
566
710
  await ensureRecovered()
567
711
  await prunePlans()
@@ -569,23 +713,57 @@ export function createBackupIntegrity(options) {
569
713
  if (activity?.hasActive === true) throw domainError('active-work')
570
714
  const source = await resolveSource(id)
571
715
  if (source === undefined) throw domainError('unknown-backup')
572
- const inspected = await inspectArchive(source, { collectEntries: true })
716
+ const inspected = await inspectArchive(source, { collectEntries: true, platform, dshHome })
573
717
  if (!inspected.report.validForRestore || inspected.parsed === null) throw domainError('backup-archive-invalid')
574
718
  // 归档可能带补丁而不带 manifest(外部工具所出):指纹与恢复操作都按并集处理,
575
719
  // 否则「只改了补丁的 profile」在 target 变更检测里是盲区。
576
- const profileNames = [...new Set([
720
+ const archivedNames = [...new Set([
577
721
  ...inspected.report.sections.profiles.items.map((item) => item.name),
578
722
  ...inspected.report.sections.profiles.patchFiles.map((item) => item.name),
579
723
  ])].sort()
580
- const patchProfileNames = inspected.report.sections.profiles.patchFiles.map((item) => item.name)
581
- const targetState = await fingerprintTargets(dshHome, profileNames)
724
+ const manifestSources = new Set(inspected.report.sections.profiles.items.map((item) => item.name))
725
+ const patchSources = new Set(inspected.report.sections.profiles.patchFiles.map((item) => item.name))
726
+ const localProfiles = await listProfiles().catch(() => [])
727
+ const normalized = normalizeProfileMapping(requestedProfiles, archivedNames, localProfiles)
728
+ if (normalized.error !== undefined) throw domainError(normalized.error)
729
+ // 「恢复什么」最终由归档决定:没带 manifest 的源不会有 manifest 操作,补丁同理。
730
+ const mapping = normalized.mapping.map((entry) => ({
731
+ source: entry.source,
732
+ target: entry.target,
733
+ manifest: entry.manifest === true && manifestSources.has(entry.source),
734
+ patch: patchSources.has(entry.source),
735
+ }))
736
+ const targetProfiles = [...new Set(mapping.map((entry) => entry.target))]
737
+ const remap = new Map(mapping.map((entry) => [entry.source, entry.target]))
738
+ // 同一份备份重复 prepare(用户改映射)时释放上一份计划:staging 是整棵归档树的
739
+ // 副本,不释放会随每次改选堆积磁盘。
740
+ for (const [existingId, existing] of [...plans]) {
741
+ if (existing.state === 'planned' && existing.source.id === source.id) {
742
+ existing.state = 'expired'
743
+ plans.delete(existingId)
744
+ await rm(existing.staging, { recursive: true, force: true })
745
+ }
746
+ }
747
+ const targetState = await fingerprintTargets(dshHome, targetProfiles)
582
748
  const planId = randomUUID()
583
749
  const staging = join(dshHome, 'backups', `.restore-plan-${planId}`)
584
750
  await materialize(inspected.parsed, staging)
751
+ // 跨平台提示按**映射后**的目标路径重算,再补一条会话目录可写探测(占用/权限)。
752
+ const notices = collectPlatformNotices(inspected.parsed.entries.map((entry) => remapProfilePath(entry.path, remap)), {
753
+ platform,
754
+ dshHome,
755
+ sourcePlatform: inspected.report.sourcePlatform ?? null,
756
+ })
757
+ const probeDetail = await probeSessionsWritable(dshHome)
758
+ if (probeDetail !== null) notices.push({ code: 'target-sessions-unwritable', detail: probeDetail })
585
759
  const preparedAt = now()
586
760
  const configPresent = new Set(inspected.report.sections.config.files.map((item) => item.name))
587
761
  const configRemove = []
588
762
  for (const name of CONFIG_FILES) if (!configPresent.has(name) && await pathExists(join(dshHome, name))) configRemove.push(name)
763
+ const manifestsReplaced = mapping.filter((entry) => entry.manifest).map((entry) => entry.target)
764
+ const patchesReplaced = mapping.filter((entry) => entry.patch).map((entry) => entry.target)
765
+ const crossName = mapping.filter((entry) => entry.source !== entry.target)
766
+ const manifestSkipped = crossName.filter((entry) => entry.manifest !== true && manifestSources.has(entry.source))
589
767
  const plan = {
590
768
  state: 'planned',
591
769
  planId,
@@ -596,28 +774,39 @@ export function createBackupIntegrity(options) {
596
774
  source: { id: source.id, name: source.name, sizeBytes: source.sizeBytes, sha256: inspected.report.source.sha256, dshVersion: inspected.report.dshVersion ?? null },
597
775
  sourceFingerprint: inspected.report.source.sha256,
598
776
  targetFingerprint: targetState.fingerprint,
599
- profileNames,
600
- patchProfileNames,
777
+ mapping,
778
+ targetProfiles,
779
+ notices: notices.slice(0, MAX_NOTICES),
601
780
  reportSummary: {
602
781
  entryCount: inspected.report.archive.entryCount,
603
782
  logicalBytes: inspected.report.archive.logicalBytes,
604
783
  sessions: inspected.report.sections.sessions,
605
784
  configFiles: inspected.report.sections.config.files.length,
606
- profiles: profileNames.length,
607
- profilePatches: patchProfileNames.length,
785
+ profiles: archivedNames.length,
786
+ profilePatches: patchSources.size,
608
787
  archiveFormat: inspected.report.archiveFormat,
609
788
  // 备份时的 DSH 版本随计划下发:恢复是不可逆动作,确认前必须让用户看清
610
789
  // 「这份快照来自哪个版本」——降级恢复读不动新格式会话,写清楚才不至于误点。
611
790
  dshVersion: inspected.report.dshVersion ?? null,
791
+ sourcePlatform: inspected.report.sourcePlatform ?? null,
612
792
  metadata: inspected.report.metadata ?? null,
613
793
  },
614
794
  targets: {
615
795
  sessions: { action: 'replace', currentBytes: targetState.bytes, newBytes: inspected.report.sections.sessions.bytes },
616
796
  config: { replace: inspected.report.sections.config.files.map((item) => item.name), remove: configRemove, newBytes: inspected.report.sections.config.bytes },
617
- profiles: { upsert: profileNames, patches: patchProfileNames, untouched: true, newBytes: inspected.report.sections.profiles.bytes },
797
+ // mapping 是恢复行为的唯一事实源;upsert/patches 是它的两个投影,供既有界面直接计数。
798
+ profiles: { mapping, upsert: manifestsReplaced, patches: patchesReplaced, untouched: true, newBytes: inspected.report.sections.profiles.bytes },
618
799
  },
619
800
  // 旧归档(v1,不含补丁层)恢复时显式告知「哪些文件没带」,不判损坏、不阻断。
620
- consequences: ['sessions-replaced', ...(configRemove.length > 0 ? ['config-files-removed'] : []), ...(profileNames.length > 0 ? ['profile-manifests-replaced'] : []), ...(patchProfileNames.length > 0 ? ['profile-patches-replaced'] : ['profile-patches-absent']), 'service-restart-required'],
801
+ consequences: [
802
+ 'sessions-replaced',
803
+ ...(configRemove.length > 0 ? ['config-files-removed'] : []),
804
+ ...(manifestsReplaced.length > 0 ? ['profile-manifests-replaced'] : []),
805
+ ...(crossName.length > 0 ? ['profile-mapped'] : []),
806
+ ...(manifestSkipped.length > 0 ? ['profile-manifest-skipped'] : []),
807
+ ...(patchesReplaced.length > 0 ? ['profile-patches-replaced'] : ['profile-patches-absent']),
808
+ 'service-restart-required',
809
+ ],
621
810
  previousInstanceId,
622
811
  runtime: { supervisorKind: runtimeEnv?.supervisorKind ?? null, manualStartLikely: runtimeEnv?.manualStartLikely === true },
623
812
  }
@@ -644,12 +833,12 @@ export function createBackupIntegrity(options) {
644
833
  if (activity?.hasActive === true) { await rm(plan.staging, { recursive: true, force: true }); throw domainError('active-work') }
645
834
  const source = await resolveSource(plan.source.id)
646
835
  if (source === undefined) { await rm(plan.staging, { recursive: true, force: true }); throw domainError('restore-source-changed') }
647
- const inspected = await inspectArchive(source)
836
+ const inspected = await inspectArchive(source, { platform, dshHome })
648
837
  if (!inspected.report.validForRestore || inspected.report.source.sha256 !== plan.sourceFingerprint) {
649
838
  await rm(plan.staging, { recursive: true, force: true })
650
839
  throw domainError('restore-source-changed')
651
840
  }
652
- const targetState = await fingerprintTargets(dshHome, plan.profileNames)
841
+ const targetState = await fingerprintTargets(dshHome, plan.targetProfiles)
653
842
  if (targetState.fingerprint !== plan.targetFingerprint) {
654
843
  await rm(plan.staging, { recursive: true, force: true })
655
844
  throw domainError('restore-target-changed')
@@ -663,15 +852,17 @@ export function createBackupIntegrity(options) {
663
852
  }
664
853
  await addOperation(join(dshHome, 'sessions'), join(plan.staging, 'sessions'), 'sessions')
665
854
  for (const name of CONFIG_FILES) await addOperation(join(dshHome, name), join(plan.staging, 'config', name), `config/${name}`)
666
- for (const name of plan.profileNames) {
667
- await addOperation(join(dshHome, 'profiles', name, 'package.json'), join(plan.staging, 'profiles', name, 'package.json'), `profiles/${name}/package.json`)
855
+ // profile 操作完全由计划的 mapping 派生:源决定读哪份暂存文件,目标决定写到哪里。
856
+ for (const entry of plan.mapping ?? []) {
857
+ if (entry.manifest === true) {
858
+ await addOperation(join(dshHome, 'profiles', entry.target, 'package.json'), join(plan.staging, 'profiles', entry.source, 'package.json'), `profiles/${entry.target}/package.json`)
859
+ }
668
860
  // 补丁层白名单恢复。归档没带(旧版 v1 所出)时**不登记**这一步:登记了就代表
669
861
  // 「按快照覆盖」,operation 循环会先把目标侧 patch 挪走、再因 staged 缺席而不回填,
670
862
  // 等于用一份对配置毫无意见的旧归档静默删掉用户当前配置。缺席只记入计划报告
671
863
  // (consequences: profile-patches-absent),不改成删除。
672
- const stagedPatch = join(plan.staging, 'profiles', name, 'cordis.patch.yml')
673
- if (await pathExists(stagedPatch)) {
674
- await addOperation(join(dshHome, 'profiles', name, 'cordis.patch.yml'), stagedPatch, `profiles/${name}/cordis.patch.yml`)
864
+ if (entry.patch === true) {
865
+ await addOperation(join(dshHome, 'profiles', entry.target, 'cordis.patch.yml'), join(plan.staging, 'profiles', entry.source, 'cordis.patch.yml'), `profiles/${entry.target}/cordis.patch.yml`)
675
866
  }
676
867
  }
677
868
  const journalPath = join(dshHome, JOURNAL_FILE)
@@ -702,7 +893,7 @@ export function createBackupIntegrity(options) {
702
893
  } catch (_) {
703
894
  throw domainError('recovery-required')
704
895
  }
705
- throw domainError('restore-failed')
896
+ throw domainError(restoreFailureCode(error, platform))
706
897
  }
707
898
 
708
899
  // 桌面端(electronShell)与终端手动启动同理:没有东西会把 Host 拉起来,恢复完成后不调度
@@ -725,4 +916,4 @@ export function createBackupIntegrity(options) {
725
916
  return { inspectBackup, verifyArchivedTree, prepareRestore, commitRestore, dispose }
726
917
  }
727
918
 
728
- export { CONFIG_FILES, PLAN_TTL_MS }
919
+ export { CONFIG_FILES, PLAN_TTL_MS, collectPlatformNotices, normalizeProfileMapping, restoreFailureCode }
package/backup-routes.js CHANGED
@@ -23,6 +23,7 @@ export function createBackupRoutes({
23
23
  formatBackupTimestamp,
24
24
  importBackup,
25
25
  listBackups,
26
+ listLocalProfiles,
26
27
  name,
27
28
  rpcFailure,
28
29
  }) {
@@ -92,7 +93,22 @@ export function createBackupRoutes({
92
93
  } },
93
94
  'backup-restore-prepare': { feature: 'backupMaintenance', audit: true, handle: async (payload, rpcEndpoint) => {
94
95
  try {
95
- return { ok: true, value: await backupIntegrity.prepareRestore(payload?.id) }
96
+ // 可选的 profile 映射:省略即「逐条同名 + 覆盖 manifest」(与既有行为一致)。
97
+ // 目标名只能是宿主清单里的名字或与源同名,判定都在宿主侧。
98
+ return { ok: true, value: await backupIntegrity.prepareRestore(payload?.id, payload?.profiles) }
99
+ } catch (error) {
100
+ return rpcFailure(error)
101
+ }
102
+
103
+ } },
104
+ // 目标 profile 清单(P1):恢复要落到本机哪个 profile 由用户在**宿主给出的清单内**选,
105
+ // 浏览器只回传名字、不传路径。本机没有 profiles 目录时返回空清单(仍可同名新建)。
106
+ 'backup-target-profiles': { feature: 'backupMaintenance', handle: async (payload, rpcEndpoint) => {
107
+ try {
108
+ const profiles = await listLocalProfiles(dshHome)
109
+ const declared = process.env.DSH_PROFILE
110
+ const active = typeof declared === 'string' && profiles.some((item) => item.name === declared) ? declared : null
111
+ return { ok: true, value: { profiles, active } }
96
112
  } catch (error) {
97
113
  return rpcFailure(error)
98
114
  }