dsh-crate-web 0.1.1 → 0.1.2-preview.2

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 (34) hide show
  1. package/README.md +11 -290
  2. package/README.zh-CN.md +2 -303
  3. package/THIRD-PARTY-LICENSE.txt +21 -0
  4. package/cordis.patch.yml +1 -6
  5. package/core/dsh_pack/_version.py +1 -0
  6. package/core/dsh_pack/bundle.py +74 -0
  7. package/core/dsh_pack/cli.py +81 -18
  8. package/core/dsh_pack/desktop_verify.py +304 -0
  9. package/core/dsh_pack/diagnostics.py +1170 -0
  10. package/core/dsh_pack/errors.py +16 -1
  11. package/core/dsh_pack/export.py +86 -103
  12. package/core/dsh_pack/export_plan.py +96 -0
  13. package/core/dsh_pack/identity.py +25 -0
  14. package/core/dsh_pack/importer.py +468 -217
  15. package/core/dsh_pack/network.py +5 -4
  16. package/core/dsh_pack/pack.py +14 -2
  17. package/core/dsh_pack/portability.py +392 -0
  18. package/core/dsh_pack/preflight.py +63 -16
  19. package/core/dsh_pack/troubleshooting.py +94 -0
  20. package/core/dsh_pack/user_cli.py +16 -0
  21. package/core/dsh_pack/validation.py +64 -4
  22. package/core/dsh_pack/verify.py +343 -194
  23. package/core/dsh_pack/windows_verify.py +177 -0
  24. package/desktop-patch-helper.mjs +140 -0
  25. package/lib/client.js +292 -102
  26. package/lib/index.d.ts +7 -1
  27. package/lib/index.js +1121 -229
  28. package/package.json +14 -7
  29. package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
  30. package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
  31. package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
  32. package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
  33. package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
  34. package/verify-desktop.mjs +314 -0
@@ -12,7 +12,10 @@ are disabled.
12
12
  from __future__ import annotations
13
13
 
14
14
  import json
15
+ import base64
15
16
  import os
17
+ import posixpath
18
+ import re
16
19
  import shutil
17
20
  import subprocess
18
21
  import tarfile
@@ -26,8 +29,10 @@ from typing import Any, Callable, Mapping
26
29
  from .errors import DshPackError, PackImportError
27
30
  from .pack import PackData, read, sha256_bytes
28
31
  from .network import npm_install_specifier
32
+ from .identity import expected_install_version, unknown_source
29
33
  from .preflight import PreflightContext, PreflightResult, inspect_pack
30
- from .validation import validate_archive_path
34
+ from .validation import validate_archive_path, bundle_patch_paths
35
+ from .bundle import check_patch_declaration, read_bundle_patches
31
36
 
32
37
 
33
38
  ReferenceInstaller = Callable[[Path, Mapping[str, Any]], None]
@@ -45,6 +50,9 @@ class ImportOptions:
45
50
  reference_installer: ReferenceInstaller | None = None
46
51
  allow_network_reference_install: bool = True
47
52
  npm_command: str | None = None
53
+ pnpm_command: tuple[str, ...] | None = None
54
+ host_bundles: Mapping[str, Any] | None = None
55
+ patch_adapter_command: tuple[str, ...] | None = None
48
56
  network_install_timeout: int = 180
49
57
  target_profile_name: str | None = None
50
58
  overwrite_existing_profile: bool = False
@@ -66,6 +74,8 @@ class ImportPlan:
66
74
  embedded_plugins: tuple[dict[str, Any], ...]
67
75
  reference_plugins: tuple[dict[str, Any], ...]
68
76
  preflight: PreflightResult
77
+ portable_data: tuple[str, ...] = ()
78
+ host_provided_dependencies: tuple[dict[str, Any], ...] = ()
69
79
 
70
80
  def as_dict(self) -> dict[str, Any]:
71
81
  return {
@@ -80,6 +90,8 @@ class ImportPlan:
80
90
  "embeddedPlugins": list(self.embedded_plugins),
81
91
  "referencePlugins": list(self.reference_plugins),
82
92
  "preflight": self.preflight.as_dict(),
93
+ "portableData": list(self.portable_data),
94
+ "hostProvidedDependencies": list(self.host_provided_dependencies),
83
95
  "decision": "READY_TO_IMPORT" if self.preflight.status == "READY" else "NOT_READY",
84
96
  }
85
97
 
@@ -269,9 +281,9 @@ def _reference_plugin_requirements(data: PackData) -> dict[str, str | None]:
269
281
  name = plugin.get("name")
270
282
  if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only" or not isinstance(name, str):
271
283
  continue
272
- resolved = plugin.get("resolved")
273
- version = resolved.get("version") if isinstance(resolved, Mapping) else None
274
- requirements[name] = version if isinstance(version, str) else None
284
+ if unknown_source(plugin):
285
+ continue # Unknown source requires an explicit, checked local path.
286
+ requirements[name] = expected_install_version(plugin)
275
287
  return requirements
276
288
 
277
289
 
@@ -470,142 +482,18 @@ def _install_network_reference(profile_path: Path, plugin: Mapping[str, Any], op
470
482
  }
471
483
 
472
484
 
473
- def _manifest_declares_workspace(content: bytes) -> bool:
474
- """Return True when an embedded tarball uses the workspace: protocol.
475
-
476
- Workspace-protocol dependencies cannot be reconciled by npm outside the
477
- DSH monorepo, and their packages are supplied by the DSH installation
478
- anchor at runtime. Such embedded packages are extracted as-is and skip
479
- the package-manager closure pass.
480
- """
481
-
482
- try:
483
- with tarfile.open(fileobj=BytesIO(content), mode="r:gz") as archive:
484
- member = archive.getmember("package/package.json")
485
- extracted = archive.extractfile(member)
486
- if extracted is None:
487
- return False
488
- manifest = json.loads(extracted.read())
489
- except (KeyError, OSError, tarfile.TarError, json.JSONDecodeError):
490
- return False
491
- if not isinstance(manifest, dict):
492
- return False
493
- declared = dict(manifest.get("dependencies") or {})
494
- declared.update(manifest.get("peerDependencies") or {})
495
- return any(
496
- isinstance(specifier, str) and specifier.startswith("workspace:")
497
- for specifier in declared.values()
498
- )
499
-
500
-
501
- def _install_embedded_dependencies(
502
- profile_path: Path,
503
- embedded_artifacts: list[tuple[dict[str, Any], bytes]],
504
- options: ImportOptions,
505
- ) -> None:
506
- """Install the dependency closure of every eligible embedded plugin.
507
-
508
- An embedded artifact is the recorded package tarball itself; it carries the
509
- plugin code but not the plugin's transitive ``dependencies``. A single npm
510
- reconciliation with every eligible embedded tarball as an install argument
511
- restores that closure so the resulting Profile can boot with the same
512
- dependency closure the source Profile had. Embedded packages that declare
513
- ``workspace:`` protocol dependencies are excluded: their closure is supplied
514
- by the DSH installation anchor at runtime. Lifecycle scripts stay disabled
515
- and the local npm cache is preferred so already-available dependencies do
516
- not require a second network round trip.
517
- """
518
-
519
- if not embedded_artifacts:
520
- return
521
- # DSH workspace packages declare their internal dependencies with the
522
- # workspace: protocol, which a plain package manager cannot reconcile
523
- # outside the monorepo. Those closures are supplied by the DSH
524
- # installation anchor at runtime, so the extracted package is kept as-is
525
- # and only plugins with a normal registry closure run through npm.
526
- eligible: list[tuple[dict[str, Any], bytes]] = []
527
- for plugin, content in embedded_artifacts:
528
- if _manifest_declares_workspace(content):
529
- continue
530
- eligible.append((plugin, content))
531
- if not eligible:
532
- return
533
- staging = Path(tempfile.mkdtemp(prefix=".dsh-pack-embedded-", dir=profile_path.parent))
534
- names = [str(plugin.get("name")) for plugin, _content in eligible]
535
- try:
536
- tarball_paths: list[str] = []
537
- for index, (_plugin, content) in enumerate(eligible):
538
- tarball = staging / f"embedded-{index}.tgz"
539
- tarball.write_bytes(content)
540
- tarball_paths.append(str(tarball))
541
- command = [
542
- _npm_command(options.npm_command),
543
- "install",
544
- "--ignore-scripts",
545
- "--legacy-peer-deps",
546
- "--no-save",
547
- "--no-package-lock",
548
- "--no-audit",
549
- "--no-fund",
550
- "--prefer-offline",
551
- "--prefix",
552
- str(profile_path),
553
- "--",
554
- *tarball_paths,
555
- ]
556
- try:
557
- completed = subprocess.run(
558
- command,
559
- cwd=profile_path,
560
- capture_output=True,
561
- text=True,
562
- check=False,
563
- timeout=max(1, int(options.network_install_timeout)),
564
- )
565
- except FileNotFoundError as error:
566
- raise PackImportError(
567
- f"npm command is unavailable while installing embedded dependencies for {', '.join(names)}",
568
- stage="embedded-install",
569
- code="EMBEDDED_NPM_UNAVAILABLE",
570
- details={"plugins": names, "command": command[0], "error": str(error)},
571
- ) from error
572
- except subprocess.TimeoutExpired as error:
573
- raise PackImportError(
574
- f"embedded dependency install timed out for {', '.join(names)}",
575
- stage="embedded-install",
576
- code="EMBEDDED_DEPENDENCIES_TIMEOUT",
577
- details={"plugins": names, "timeoutSeconds": options.network_install_timeout},
578
- ) from error
579
- except OSError as error:
580
- raise PackImportError(
581
- f"cannot start npm while installing embedded dependencies for {', '.join(names)}",
582
- stage="embedded-install",
583
- code="EMBEDDED_NPM_FAILED",
584
- details={"plugins": names, "command": command[0], "error": str(error)},
585
- ) from error
586
- if completed.returncode != 0:
587
- raise PackImportError(
588
- f"embedded dependency install failed for {', '.join(names)} with exit code {completed.returncode}",
589
- stage="embedded-install",
590
- code="EMBEDDED_DEPENDENCIES_INSTALL_FAILED",
591
- details={
592
- "plugins": names,
593
- "command": command,
594
- "exitCode": completed.returncode,
595
- "stdout": _network_error_output(completed.stdout),
596
- "stderr": _network_error_output(completed.stderr),
597
- },
598
- )
599
- finally:
600
- _cleanup(staging)
601
-
602
-
603
485
  def _context(options: ImportOptions) -> PreflightContext:
486
+ verified_local_plugins = {}
487
+ for name, source in options.reference_plugin_sources.items():
488
+ manifest = _package_manifest(Path(source), stage="planning")
489
+ if manifest["name"] == name:
490
+ verified_local_plugins[name] = manifest["version"]
604
491
  return PreflightContext(
605
492
  current_environment=dict(options.current_environment),
606
493
  available_secrets=set() if options.available_secrets is None else set(options.available_secrets),
607
494
  available_plugins=_reference_inventory(options),
608
495
  allow_network_reference_install=options.allow_network_reference_install,
496
+ verified_local_plugins=verified_local_plugins,
609
497
  )
610
498
 
611
499
 
@@ -617,11 +505,10 @@ def _plugin_rows(lock: Mapping[str, Any], mode: str) -> tuple[dict[str, Any], ..
617
505
  artifact = plugin.get("artifact")
618
506
  if not isinstance(artifact, Mapping) or artifact.get("mode") != mode:
619
507
  continue
620
- resolved = plugin.get("resolved")
621
508
  result.append(
622
509
  {
623
510
  "name": plugin.get("name"),
624
- "version": resolved.get("version") if isinstance(resolved, Mapping) else None,
511
+ "version": expected_install_version(plugin),
625
512
  "required": plugin.get("required") is True,
626
513
  "artifact": dict(artifact),
627
514
  }
@@ -668,6 +555,8 @@ def create_import_plan(
668
555
  except DshPackError as error:
669
556
  raise PackImportError(str(error), stage="preflight", code="PACK_READ_ERROR") from error
670
557
 
558
+ from .portability import check_targets
559
+ check_targets(home_path, data.user_data, overwrite=options.overwrite_existing_profile)
671
560
  requested_profile_name = _profile_name(data.manifest)
672
561
  profile_name = _select_profile_name(
673
562
  home_path,
@@ -694,6 +583,7 @@ def create_import_plan(
694
583
  code="PROFILE_CONFIG_MISSING",
695
584
  )
696
585
 
586
+ _, host_dependencies = _host_dependency_overrides(data, options)
697
587
  return ImportPlan(
698
588
  pack_path=source_path,
699
589
  dsh_home=home_path,
@@ -706,6 +596,8 @@ def create_import_plan(
706
596
  embedded_plugins=_plugin_rows(data.plugins_lock, "embedded"),
707
597
  reference_plugins=_plugin_rows(data.plugins_lock, "reference-only"),
708
598
  preflight=preflight,
599
+ portable_data=tuple(data.user_data),
600
+ host_provided_dependencies=tuple(host_dependencies),
709
601
  )
710
602
 
711
603
 
@@ -928,8 +820,7 @@ def _copy_reference_source(profile_path: Path, plugin: Mapping[str, Any], source
928
820
 
929
821
  def _verify_identity(profile_path: Path, plugin: Mapping[str, Any]) -> dict[str, Any]:
930
822
  name = str(plugin["name"])
931
- resolved = plugin.get("resolved")
932
- expected_version = resolved.get("version") if isinstance(resolved, Mapping) else None
823
+ expected_version = expected_install_version(plugin)
933
824
  installed = _package_manifest(_package_install_path(profile_path, name), stage="version-confirmation")
934
825
  if installed["name"] != name or installed["version"] != expected_version:
935
826
  raise PackImportError(
@@ -942,10 +833,322 @@ def _verify_identity(profile_path: Path, plugin: Mapping[str, Any]) -> dict[str,
942
833
  "observed": {"name": installed.get("name"), "version": installed.get("version")},
943
834
  },
944
835
  )
836
+ bundle = plugin.get("bundle")
837
+ if isinstance(bundle, Mapping) and bundle.get("enabled") is True:
838
+ patch = bundle.get("patch")
839
+ package_root = _package_install_path(profile_path, name)
840
+ try:
841
+ paths = bundle_patch_paths(patch)
842
+ for relative in paths:
843
+ package_root.joinpath(*relative.split("/")).resolve().relative_to(package_root.resolve())
844
+ except (DshPackError, ValueError) as error:
845
+ raise PackImportError(
846
+ f"installed Bundle has an unsafe patch path: {name}",
847
+ stage="version-confirmation", code="BUNDLE_PATCH_INVALID",
848
+ details={"plugin": name, "patch": patch},
849
+ ) from error
850
+ try:
851
+ check_patch_declaration(installed, patch)
852
+ except DshPackError as error:
853
+ raise PackImportError(
854
+ f"installed Bundle patch declaration differs from Crate: {name}",
855
+ stage="version-confirmation", code="BUNDLE_PATCH_MISMATCH",
856
+ details={"plugin": name, "expected": patch},
857
+ ) from error
858
+ for relative in paths:
859
+ if not package_root.joinpath(*relative.split("/")).is_file():
860
+ raise PackImportError(
861
+ f"installed Bundle patch is missing: {name}/{relative}",
862
+ stage="version-confirmation", code="BUNDLE_PATCH_MISSING",
863
+ details={"plugin": name, "patch": relative},
864
+ )
865
+ try:
866
+ read_bundle_patches(package_root, patch)
867
+ except DshPackError as error:
868
+ raise PackImportError(
869
+ f"installed Bundle patch content is unsafe: {name}",
870
+ stage="version-confirmation", code="BUNDLE_PATCH_INVALID",
871
+ details={"plugin": name, "patch": patch},
872
+ ) from error
945
873
  return {"name": name, "version": installed["version"], "mode": plugin["artifact"]["mode"]}
946
874
 
947
875
 
948
- def _metadata_payload(plan: ImportPlan, data: PackData, installed: tuple[dict[str, Any], ...], pack_digest: str) -> dict[str, Any]:
876
+ def _remove_staged_package(profile_path: Path, name: str, *, stage: str) -> None:
877
+ """Replace only a package inside the temporary Profile, without following links."""
878
+ target = _package_install_path(profile_path, name)
879
+ # A package manager can create scoped directories or links. Reject linked
880
+ # ancestors before removing anything; never resolve through them for deletion.
881
+ for parent in target.parents:
882
+ if parent == profile_path:
883
+ break
884
+ if parent.is_symlink() or (getattr(parent, "is_junction", lambda: False)()):
885
+ raise PackImportError(
886
+ f"staged package ancestor is a link: {name}",
887
+ stage=stage, code="PLUGIN_TARGET_UNSAFE",
888
+ details={"plugin": name},
889
+ )
890
+ if target.is_symlink():
891
+ target.unlink()
892
+ elif getattr(target, "is_junction", lambda: False)():
893
+ target.rmdir()
894
+ elif target.is_dir():
895
+ shutil.rmtree(target)
896
+ elif target.exists():
897
+ target.unlink()
898
+
899
+
900
+ def _write_network_manifest(profile_path: Path, dependencies: Mapping[str, str]) -> None:
901
+ # The exported Profile can contain local paths, embedded package registry
902
+ # requests, or optional sources that failed. npm must only see successful
903
+ # network references; the original Profile manifest is restored afterwards.
904
+ (profile_path / "package.json").write_text(
905
+ json.dumps({"name": "dsh-crate-import-staging", "private": True,
906
+ "dependencies": dict(dependencies)}), encoding="utf-8",
907
+ )
908
+
909
+
910
+ def _network_dependency_spec(plugin: Mapping[str, Any]) -> str:
911
+ specifier = npm_install_specifier(plugin)
912
+ assert specifier is not None # the network plan already checked support
913
+ prefix = f"{plugin['name']}@"
914
+ return specifier[len(prefix):] if specifier.startswith(prefix) else specifier
915
+
916
+
917
+ def _host_dependency_overrides(data: PackData, options: ImportOptions) -> tuple[dict[str, str], list[dict[str, Any]]]:
918
+ """Use dependencies from a verified installation anchor, never by name alone.
919
+
920
+ Desktop supplies public manifest/file evidence read from its actual ASAR.
921
+ The Bundle artifact itself remains installed and managed by pnpm. Only its
922
+ already-present host dependencies are omitted from the Profile's pnpm tree,
923
+ where a second copy of host/native internals could shadow the official host.
924
+ """
925
+ if options.host_bundles is None:
926
+ return {}, []
927
+ overrides: dict[str, str] = {}
928
+ provided: list[dict[str, Any]] = []
929
+ for plugin in data.plugins_lock['plugins']:
930
+ if not plugin.get('bundle', {}).get('enabled') or plugin.get('runtime', {}).get('source') != 'installation-anchor':
931
+ continue
932
+ artifact = plugin.get('artifact', {})
933
+ if artifact.get('mode') != 'embedded':
934
+ continue
935
+ name = str(plugin['name'])
936
+ host = options.host_bundles.get(name)
937
+ def mismatch(message):
938
+ return PackImportError(message, stage='planning', code='HOST_BUNDLE_MISMATCH', details={
939
+ 'plugin': name, 'suggestedNextStep': '请使用与 Crate 实际制品一致的官方桌面安装锚点,或重新导出当前环境;不要用另一份宿主依赖覆盖原生运行时。'})
940
+ if not isinstance(host, Mapping) or not isinstance(host.get('manifest'), Mapping) or not isinstance(host.get('files'), Mapping):
941
+ raise mismatch('the target host has no verified evidence for an installation-anchor Bundle')
942
+ content = data.plugin_artifacts[artifact['path'][len('plugins/'):]]
943
+ try:
944
+ archive_context = tarfile.open(fileobj=BytesIO(content), mode='r:gz')
945
+ except (tarfile.TarError, OSError) as error:
946
+ raise mismatch('the host Bundle artifact cannot be read') from error
947
+ with archive_context as archive:
948
+ try:
949
+ manifest_member = archive.getmember('package/package.json')
950
+ if not manifest_member.isfile():
951
+ raise mismatch('the host Bundle manifest must be a regular file')
952
+ manifest = json.loads(archive.extractfile(manifest_member).read())
953
+ except (tarfile.TarError, KeyError, ValueError, OSError) as error:
954
+ raise mismatch('the host Bundle artifact has no readable package manifest') from error
955
+ if manifest != host['manifest'] or manifest.get('version') != expected_install_version(plugin):
956
+ raise mismatch('the host Bundle manifest differs from the embedded artifact')
957
+ for member in archive:
958
+ if member.isdir():
959
+ continue
960
+ relative = member.name.removeprefix('package/')
961
+ if relative == 'package.json':
962
+ continue # compared semantically above, including dependencies
963
+ if member.isfile():
964
+ payload = archive.extractfile(member).read()
965
+ elif member.islnk():
966
+ payload = _read_artifact_hardlink(archive, member)
967
+ else:
968
+ raise mismatch('the host Bundle artifact contains unsupported members')
969
+ if host['files'].get(relative) != sha256_bytes(payload):
970
+ raise mismatch('the host Bundle files differ from the embedded artifact')
971
+ installed_dependencies = host.get('dependencies', {})
972
+ if not isinstance(installed_dependencies, Mapping):
973
+ raise mismatch('the host dependency evidence is invalid')
974
+ for field in ('dependencies', 'optionalDependencies'):
975
+ for dependency, requested in manifest.get(field, {}).items():
976
+ version = installed_dependencies.get(dependency)
977
+ if not isinstance(version, str) or not version:
978
+ if field == 'optionalDependencies':
979
+ continue
980
+ raise mismatch('a declared Bundle dependency is missing from the target host')
981
+ overrides[name + '>' + dependency] = '-'
982
+ provided.append({'bundle': name, 'name': dependency, 'requested': requested,
983
+ 'runtimeVersion': version, 'source': 'verified-installation-anchor'})
984
+ return overrides, provided
985
+
986
+
987
+ def _adapt_profile_patch(profile: Path, options: ImportOptions) -> dict[str, Any] | None:
988
+ if not options.patch_adapter_command:
989
+ return None
990
+ path = profile / 'cordis.patch.yml'
991
+ original = path.read_bytes() if path.is_file() else None
992
+ command = [*options.patch_adapter_command, str(profile)]
993
+ completed = None
994
+ def safe_output(value: Any) -> str:
995
+ text = value.decode('utf-8', errors='replace') if isinstance(value, bytes) else str(value or '')
996
+ for name, secret in os.environ.items():
997
+ if secret and re.search(r'KEY|TOKEN|PASSWORD|SECRET|COOKIE|AUTH|PRIVATE|CREDENTIAL', name, re.I):
998
+ text = text.replace(secret, '[REDACTED]')
999
+ text = re.sub(r'(?i)(Bearer\s+)[^\s"\'<>]+', r'\1[REDACTED]', text)
1000
+ return re.sub(r'(?i)([?&](?:token|access_token|auth|api_key)=)[^\s&#"\'<>]+', r'\1[REDACTED]', text)
1001
+ try:
1002
+ completed = subprocess.run(command, cwd=profile,
1003
+ env=dict(os.environ, ELECTRON_RUN_AS_NODE='1'), capture_output=True,
1004
+ text=True, timeout=max(1, options.network_install_timeout), check=False)
1005
+ result = json.loads(completed.stdout)
1006
+ if completed.returncode or not isinstance(result, dict) or result.get('error'):
1007
+ raise ValueError('the official patch equivalence adapter failed')
1008
+ if type(result.get('changed')) is not bool:
1009
+ raise ValueError('patch adapter changed flag is invalid')
1010
+ if result.get('changed') is not True:
1011
+ if not isinstance(result.get('reason'), str) or not result['reason']:
1012
+ raise ValueError('patch adapter unchanged evidence is invalid')
1013
+ return {'changed': False, 'reason': result.get('reason', 'unchanged')}
1014
+ content = base64.b64decode(result['contentBase64'], validate=True)
1015
+ if original is None or sha256_bytes(original) != result['originalSha256'] or sha256_bytes(content) != result['runtimeSha256']:
1016
+ raise ValueError('patch adapter content identity mismatch')
1017
+ if path.read_bytes() != original:
1018
+ raise ValueError('patch changed while the equivalence adapter was running')
1019
+ changes = result.get('removedNames')
1020
+ tree_hash = result.get('loaderTreeSha256')
1021
+ if (not isinstance(changes, list) or not changes or any(not isinstance(row, dict) or set(row) != {'id', 'name'}
1022
+ or not all(isinstance(value, str) and value for value in row.values()) for row in changes)
1023
+ or not isinstance(tree_hash, str) or not re.fullmatch('[a-f0-9]{64}', tree_hash)):
1024
+ raise ValueError('patch adapter equivalence evidence is missing')
1025
+ backup = profile / '.dsh-crate/original-profile-config/cordis.patch.yml'
1026
+ backup.write_bytes(original)
1027
+ path.write_bytes(content)
1028
+ return {'changed': True, 'originalSha256': sha256_bytes(original),
1029
+ 'runtimeSha256': sha256_bytes(content), 'loaderTreeSha256': tree_hash,
1030
+ 'removedNames': changes, 'adapter': 'official-desktop-composition-equivalence-v1'}
1031
+ except (OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired) as error:
1032
+ raise PackImportError('cannot preserve official Loader composition while adapting the target patch',
1033
+ stage='configuration', code='PROFILE_PATCH_ADAPTATION_FAILED',
1034
+ details={'command': command, 'reason': safe_output(str(error)),
1035
+ 'exitCode': completed.returncode if completed is not None else None,
1036
+ 'stdout': safe_output(completed.stdout if completed is not None else getattr(error, 'stdout', '')),
1037
+ 'stderr': safe_output(completed.stderr if completed is not None else getattr(error, 'stderr', '')),
1038
+ 'suggestedNextStep': '保留源 Profile 和原始 Crate,检查官方解析器及完整诊断;不要删除必需插件或绕过配置等价校验。'}) from error
1039
+
1040
+
1041
+ def _pnpm_rebuild(profile: Path, data: PackData, plugins: list[Mapping[str, Any]], options: ImportOptions) -> dict[str, Any]:
1042
+ """Reconstruct manager-owned state from verified artifacts, never copy it.
1043
+
1044
+ Source SPEC remains in the Pack and original-package.json. The restored
1045
+ Profile deliberately requests stable local artifacts so later official
1046
+ manager operations do not depend on paths on the exporting machine.
1047
+ """
1048
+ command = options.pnpm_command
1049
+ assert command
1050
+ overrides, host_dependencies = _host_dependency_overrides(data, options)
1051
+ artifacts = profile / '.dsh-crate' / 'artifacts'
1052
+ artifacts.mkdir(parents=True, exist_ok=False)
1053
+ original = data.profile_files['package.json']
1054
+ (artifacts.parent / 'original-package.json').write_bytes(original)
1055
+ manifest = json.loads(original)
1056
+ dependencies: dict[str, str] = {}
1057
+ for plugin in plugins:
1058
+ name = str(plugin['name'])
1059
+ artifact = plugin['artifact']
1060
+ if artifact['mode'] == 'embedded':
1061
+ content = data.plugin_artifacts[artifact['path'][len('plugins/'):]]
1062
+ else:
1063
+ # The existing installer resolved and verified this reference.
1064
+ # Turn its portable package files into a local rebuild artifact.
1065
+ source = _package_install_path(profile, name)
1066
+ buffer = BytesIO()
1067
+ with tarfile.open(fileobj=buffer, mode='w:gz') as archive:
1068
+ for item in sorted(source.rglob('*')):
1069
+ relative = item.relative_to(source)
1070
+ if any(part in _REFERENCE_SOURCE_SKIP_DIRS for part in relative.parts):
1071
+ continue
1072
+ if item.is_symlink() or getattr(item, 'is_junction', lambda: False)():
1073
+ raise PackImportError('reference artifact contains a link', stage='configuration', code='PLUGIN_TARGET_UNSAFE', details={'plugin': name})
1074
+ if item.is_file():
1075
+ archive.add(item, arcname='package/' + relative.as_posix(), recursive=False)
1076
+ content = buffer.getvalue()
1077
+ filename = sha256_bytes(content) + '.tgz'
1078
+ (artifacts / filename).write_bytes(content)
1079
+ dependencies[name] = 'file:./.dsh-crate/artifacts/' + filename
1080
+ _remove_staged_package(profile, name, stage='configuration')
1081
+ manifest['dependencies'] = dependencies
1082
+ # Imported optional packages are represented once in the rebuilt tree.
1083
+ manifest.pop('optionalDependencies', None)
1084
+ for field in ('pnpm', 'scripts', 'devDependencies', 'packageManager'):
1085
+ manifest.pop(field, None)
1086
+ (profile / 'package.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
1087
+ # Source workspace/auth/hook configuration must not control the rebuild or
1088
+ # later official manager operations. Keep the originals as inert evidence.
1089
+ originals = artifacts.parent / 'original-profile-config'
1090
+ originals.mkdir()
1091
+ for name in ('pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.pnpmfile.mjs'):
1092
+ path = profile / name
1093
+ if path.exists():
1094
+ path.rename(originals / name)
1095
+ (profile / 'pnpm-workspace.yaml').write_text(
1096
+ 'packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\nignorePnpmfile: true\nignoreScripts: true\nrecursiveInstall: false\npackageImportMethod: copy\n'
1097
+ + 'overrides: ' + json.dumps(overrides,ensure_ascii=False) + '\n', encoding='utf-8')
1098
+ # The source lock describes a different machine and different requests.
1099
+ (profile / 'pnpm-lock.yaml').unlink(missing_ok=True)
1100
+ args = [*command, 'install', '--ignore-scripts', '--prod', '--no-frozen-lockfile',
1101
+ '--node-linker=hoisted', '--config.auto-install-peers=false',
1102
+ '--config.manage-package-manager-versions=false', '--config.ignore-pnpmfile=true',
1103
+ '--config.recursive-install=false', '--config.lockfile-dir=' + str(profile),
1104
+ '--config.modules-dir=' + str(profile / 'node_modules'), '--config.virtual-store-dir=' + str(profile / 'node_modules/.pnpm'),
1105
+ # Hard-linking a cached package into another Profile changes the
1106
+ # existing file's ctime. DSH uses ctime in client module identities,
1107
+ # so that can remount the source UI during its own Import request.
1108
+ '--config.package-import-method=copy',
1109
+ '--config.enable-global-virtual-store=false', '--config.shared-workspace-lockfile=false']
1110
+ if not options.allow_network_reference_install:
1111
+ args.append('--offline')
1112
+ environment = dict(os.environ, ELECTRON_RUN_AS_NODE='1')
1113
+ try:
1114
+ completed = subprocess.run(args, cwd=profile, env=environment, capture_output=True, text=True,
1115
+ timeout=max(1, options.network_install_timeout), check=False)
1116
+ except (OSError, subprocess.TimeoutExpired) as error:
1117
+ raise PackImportError('cannot rebuild the target with pnpm', stage='configuration', code='PACKAGE_MANAGER_FAILED', details={'error': str(error), 'command': args}) from error
1118
+ if completed.returncode:
1119
+ raise PackImportError('pnpm could not rebuild the target installation', stage='configuration', code='PACKAGE_MANAGER_FAILED', details={
1120
+ 'command': args, 'exitCode': completed.returncode,
1121
+ 'stdout': _network_error_output(completed.stdout), 'stderr': _network_error_output(completed.stderr)})
1122
+ if not (profile / 'node_modules/.modules.yaml').is_file() or not (profile / 'pnpm-lock.yaml').is_file():
1123
+ raise PackImportError('pnpm did not create the required installation records', stage='configuration', code='PACKAGE_MANAGER_STATE_MISSING')
1124
+ # pnpm 11 writes this record as JSON, even with its legacy .yaml suffix.
1125
+ # Its reader supports a virtualStoreDir relative to node_modules on all
1126
+ # platforms, while its Windows writer emits an absolute staging path.
1127
+ # Only normalize the generated hoisted record we just installed; never
1128
+ # copy machine installation state out of a Pack or alter user profiles.
1129
+ modules_path = profile / 'node_modules/.modules.yaml'
1130
+ try:
1131
+ modules = json.loads(modules_path.read_text(encoding='utf-8'))
1132
+ virtual_store = Path(modules['virtualStoreDir'])
1133
+ if not virtual_store.is_absolute():
1134
+ virtual_store = profile / 'node_modules' / virtual_store
1135
+ if modules.get('nodeLinker') != 'hoisted' or virtual_store.resolve() != (profile / 'node_modules/.pnpm').resolve():
1136
+ raise ValueError(f"unexpected generated virtual store: {modules.get('nodeLinker')} / {modules.get('virtualStoreDir')}")
1137
+ modules['virtualStoreDir'] = '.pnpm'
1138
+ modules_path.write_text(json.dumps(modules, indent=2) + '\n', encoding='utf-8')
1139
+ except (ValueError, KeyError, TypeError) as error:
1140
+ raise PackImportError('pnpm installation records cannot be safely relocated', stage='configuration', code='PACKAGE_MANAGER_STATE_UNSUPPORTED', details={'error': str(error)}) from error
1141
+ adaptation = _adapt_profile_patch(profile, options)
1142
+ return {'resolver': 'pnpm', 'scripts': 'ignored', 'packageImportMethod': 'copy', 'requests': dependencies,
1143
+ **({'profilePatchAdaptation': adaptation} if adaptation is not None else {}),
1144
+ 'hostProvidedDependencies': host_dependencies,
1145
+ 'workspaceSha256': sha256_bytes((profile / 'pnpm-workspace.yaml').read_bytes()),
1146
+ 'originalManifest': '.dsh-crate/original-package.json',
1147
+ 'originalManifestSha256': sha256_bytes(original),
1148
+ 'stdout': _network_error_output(completed.stdout)}
1149
+
1150
+
1151
+ def _metadata_payload(plan: ImportPlan, data: PackData, installed: tuple[dict[str, Any], ...], pack_digest: str, installation: Mapping[str, Any] | None = None) -> dict[str, Any]:
949
1152
  return {
950
1153
  "schemaVersion": 1,
951
1154
  "status": "prepared",
@@ -954,6 +1157,7 @@ def _metadata_payload(plan: ImportPlan, data: PackData, installed: tuple[dict[st
954
1157
  "overwrite": plan.overwrite,
955
1158
  "plugins": list(installed),
956
1159
  "requiredSecrets": list(data.manifest.get("requiredSecrets", [])),
1160
+ **({'installation': {key: value for key, value in installation.items() if key != 'stdout'}} if installation else {}),
957
1161
  }
958
1162
 
959
1163
 
@@ -1026,6 +1230,10 @@ def import_pack(
1026
1230
  except DshPackError as error:
1027
1231
  raise PackImportError(str(error), stage="preflight", code="PACK_READ_ERROR") from error
1028
1232
 
1233
+ from .portability import check_targets, restore_files
1234
+ check_targets(plan.dsh_home, data.user_data, overwrite=plan.overwrite)
1235
+ data_created: list[Path] = []
1236
+ data_directories: list[Path] = []
1029
1237
  profiles_root = plan.dsh_home / "profiles"
1030
1238
  temp_profile: Path | None = None
1031
1239
  final_created = False
@@ -1044,23 +1252,30 @@ def import_pack(
1044
1252
  profiles_root.mkdir(parents=True, exist_ok=True)
1045
1253
  temp_profile = Path(tempfile.mkdtemp(prefix=f".{plan.profile_name}.dsh-pack-", dir=profiles_root))
1046
1254
  for relative, content in data.profile_files.items():
1255
+ # Older Crate exporters included their own target installation
1256
+ # records. Never restore these as authoritative state; rebuild
1257
+ # them from the verified plugin artifacts below.
1258
+ if relative.split('/')[0].casefold() == '.dsh-crate':
1259
+ continue
1047
1260
  _write_file(temp_profile, relative, content)
1048
1261
 
1049
- installed: dict[str, dict[str, Any]] = {}
1262
+ installed: list[dict[str, Any]] = []
1050
1263
  lock_plugins = [plugin for plugin in data.plugins_lock.get("plugins", []) if isinstance(plugin, Mapping)]
1051
-
1052
- # Network reference installs run first: npm reconciles the whole
1053
- # Profile tree at the temporary prefix and prunes packages that are not
1054
- # part of its resolution. Manually placed packages (embedded extracts
1055
- # and local reference copies) must therefore be written only after every
1056
- # npm operation has finished, or npm would delete them.
1264
+ installed_names: set[str] = set()
1265
+
1266
+ # npm reconciles the entire tree and can prune previously extracted
1267
+ # packages. Complete every npm operation before materializing embedded
1268
+ # artifacts and explicit local sources.
1269
+ network_dependencies: dict[str, str] = {}
1270
+ if network_plugins:
1271
+ _write_network_manifest(temp_profile, network_dependencies)
1057
1272
  for plugin in lock_plugins:
1058
1273
  artifact = plugin.get("artifact")
1059
1274
  if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only":
1060
1275
  continue
1061
1276
  name = str(plugin["name"])
1062
1277
  if options.reference_installer is not None or name in options.reference_plugin_sources:
1063
- continue # handled by the local-source pass below
1278
+ continue
1064
1279
  if not (options.allow_network_reference_install and name in network_plugins):
1065
1280
  continue
1066
1281
  try:
@@ -1068,43 +1283,36 @@ def import_pack(
1068
1283
  except PackImportError as error:
1069
1284
  if plugin.get("required") is True:
1070
1285
  raise
1286
+ _remove_staged_package(temp_profile, name, stage="network-install")
1071
1287
  warnings.append(error.as_dict())
1072
1288
  continue
1073
- installed[name] = _verify_identity(temp_profile, plugin)
1074
-
1075
- # Embedded artifacts are self-contained package tarballs. Collect
1076
- # them, install the eligible dependency closure through the package
1077
- # manager BEFORE any manual placement so a reconciling npm run cannot
1078
- # prune manually placed packages, then extract whatever npm did not
1079
- # place (workspace-protocol packages whose closure is supplied by the
1080
- # DSH installation anchor).
1081
- embedded_artifacts: list[tuple[dict[str, Any], bytes]] = []
1289
+ installed_names.add(name)
1290
+ network_dependencies[name] = _network_dependency_spec(plugin)
1291
+ _write_network_manifest(temp_profile, network_dependencies)
1292
+ if network_plugins:
1293
+ (temp_profile / "package.json").write_bytes(data.profile_files["package.json"])
1294
+
1082
1295
  for plugin in lock_plugins:
1083
1296
  artifact = plugin.get("artifact")
1084
1297
  if not isinstance(artifact, Mapping) or artifact.get("mode") != "embedded":
1085
1298
  continue
1086
- name = str(plugin["name"])
1087
1299
  path = artifact.get("path")
1088
1300
  if not isinstance(path, str):
1089
1301
  raise PackImportError(
1090
- f"embedded artifact path missing for {name}",
1302
+ f"embedded artifact path missing for {plugin.get('name')}",
1091
1303
  stage="embedded-install",
1092
1304
  code="ARTIFACT_PATH_MISSING",
1093
1305
  )
1094
1306
  content = data.plugin_artifacts.get(path[len("plugins/"):])
1095
1307
  if content is None:
1096
1308
  raise PackImportError(
1097
- f"embedded artifact missing for {name}: {path}",
1309
+ f"embedded artifact missing for {plugin.get('name')}: {path}",
1098
1310
  stage="embedded-install",
1099
1311
  code="REQUIRED_ARTIFACT_MISSING",
1100
1312
  )
1101
- embedded_artifacts.append((plugin, content))
1102
- _install_embedded_dependencies(temp_profile, embedded_artifacts, options)
1103
- for plugin, _content in embedded_artifacts:
1104
- name = str(plugin["name"])
1105
- if not _package_install_path(temp_profile, name).exists():
1106
- _extract_embedded_artifact(temp_profile, plugin, _content)
1107
- installed[name] = _verify_identity(temp_profile, plugin)
1313
+ _remove_staged_package(temp_profile, str(plugin["name"]), stage="embedded-install")
1314
+ _extract_embedded_artifact(temp_profile, plugin, content)
1315
+ installed_names.add(str(plugin["name"]))
1108
1316
 
1109
1317
  for plugin in lock_plugins:
1110
1318
  artifact = plugin.get("artifact")
@@ -1123,14 +1331,15 @@ def import_pack(
1123
1331
  code="PLUGIN_INSTALL_FAILED",
1124
1332
  details={"plugin": name, "error": str(error)},
1125
1333
  ) from error
1126
- installed[name] = _verify_identity(temp_profile, plugin)
1334
+ installed_names.add(name)
1127
1335
  continue
1128
1336
  if name in options.reference_plugin_sources:
1337
+ _remove_staged_package(temp_profile, name, stage="reference-install")
1129
1338
  _copy_reference_source(temp_profile, plugin, Path(options.reference_plugin_sources[name]))
1130
- installed[name] = _verify_identity(temp_profile, plugin)
1339
+ installed_names.add(name)
1131
1340
  continue
1132
1341
  if options.allow_network_reference_install and name in network_plugins:
1133
- continue # already installed and verified in the network pass
1342
+ continue # final identity verification follows all installs
1134
1343
  if plugin.get("required") is True:
1135
1344
  raise PackImportError(
1136
1345
  f"required reference-only plugin was not installed: {name}",
@@ -1139,18 +1348,19 @@ def import_pack(
1139
1348
  details={"plugin": name},
1140
1349
  )
1141
1350
 
1142
- # Re-verify every installed package after the final install pass so a
1143
- # package-manager reconciliation that pruned a manually placed package
1144
- # is caught here instead of surfacing as a Profile that cannot boot.
1145
- for plugin in lock_plugins:
1146
- artifact = plugin.get("artifact")
1147
- if not isinstance(artifact, Mapping) or artifact.get("mode") not in ("embedded", "reference-only"):
1148
- continue
1149
- name = str(plugin["name"])
1150
- if name not in installed:
1151
- continue
1152
- installed[name] = _verify_identity(temp_profile, plugin)
1153
-
1351
+ # A later npm operation or installer can replace an earlier package.
1352
+ # Confirm the final tree, not stale per-install observations, before any
1353
+ # portable Home data is written or the prepared Profile is committed.
1354
+ if options.pnpm_command:
1355
+ selected = [plugin for plugin in lock_plugins if str(plugin['name']) in installed_names]
1356
+ # Validate the inputs before letting the manager consume them.
1357
+ for plugin in selected:
1358
+ _verify_identity(temp_profile, plugin)
1359
+ network_installs.append(_pnpm_rebuild(temp_profile, data, selected, options))
1360
+ installed = [_verify_identity(temp_profile, plugin) for plugin in lock_plugins
1361
+ if str(plugin["name"]) in installed_names]
1362
+
1363
+ restore_files(plan.dsh_home, data.user_data, data_created, data_directories)
1154
1364
  pack_digest = sha256_bytes(plan.pack_path.read_bytes())
1155
1365
  final_profile = plan.profile_path
1156
1366
  if final_profile.exists():
@@ -1181,7 +1391,8 @@ def import_pack(
1181
1391
  (plan.metadata_path / "manifest.json").write_bytes(json.dumps(data.manifest, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + b"\n")
1182
1392
  (plan.metadata_path / "plugins.lock.json").write_bytes(json.dumps(data.plugins_lock, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + b"\n")
1183
1393
  (plan.metadata_path / "prepared.json").write_bytes(
1184
- json.dumps(_metadata_payload(plan, data, tuple(installed.values()), pack_digest), ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + b"\n"
1394
+ json.dumps(_metadata_payload(plan, data, tuple(installed), pack_digest,
1395
+ network_installs[-1] if options.pnpm_command else None), ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + b"\n"
1185
1396
  )
1186
1397
  for backup in (original_profile_backup, original_metadata_backup):
1187
1398
  if backup is None:
@@ -1203,52 +1414,93 @@ def import_pack(
1203
1414
  profile_path=final_profile,
1204
1415
  metadata_path=plan.metadata_path,
1205
1416
  plan=plan,
1206
- installed_plugins=tuple(installed.values()),
1417
+ installed_plugins=tuple(installed),
1207
1418
  network_installs=tuple(network_installs),
1208
1419
  warnings=tuple(warnings),
1209
1420
  )
1210
- except PackImportError:
1211
- _cleanup(temp_profile)
1212
- if final_created:
1213
- _cleanup(plan.profile_path)
1214
- if metadata_created:
1215
- _cleanup(plan.metadata_path)
1216
- if original_profile_backup is not None and original_profile_backup.exists() and not plan.profile_path.exists():
1217
- original_profile_backup.rename(plan.profile_path)
1218
- if original_metadata_backup is not None and original_metadata_backup.exists() and not plan.metadata_path.exists():
1219
- original_metadata_backup.rename(plan.metadata_path)
1220
- if not metadata_parent_existed:
1221
- _cleanup_empty(metadata_parent)
1222
- if not metadata_root_existed:
1223
- _cleanup_empty(metadata_root)
1224
- raise
1225
- except (OSError, tarfile.TarError, ValueError) as error:
1226
- _cleanup(temp_profile)
1227
- if final_created:
1228
- _cleanup(plan.profile_path)
1229
- if metadata_created:
1230
- _cleanup(plan.metadata_path)
1231
- if original_profile_backup is not None and original_profile_backup.exists() and not plan.profile_path.exists():
1232
- original_profile_backup.rename(plan.profile_path)
1233
- if original_metadata_backup is not None and original_metadata_backup.exists() and not plan.metadata_path.exists():
1234
- original_metadata_backup.rename(plan.metadata_path)
1235
- if not metadata_parent_existed:
1236
- _cleanup_empty(metadata_parent)
1237
- if not metadata_root_existed:
1238
- _cleanup_empty(metadata_root)
1239
- raise PackImportError(
1421
+ except (DshPackError, OSError, tarfile.TarError, ValueError) as error:
1422
+ # Preserve the first failure before compensation. A Windows sharing
1423
+ # lock may reject both the commit rename and subsequent cleanup.
1424
+ # Cleanup must not replace that error or claim an unobserved rollback.
1425
+ observed = {"error": str(error), "errorType": type(error).__name__}
1426
+ if isinstance(error, OSError):
1427
+ observed.update({key: getattr(error, key, None)
1428
+ for key in ("errno", "winerror", "filename", "filename2")})
1429
+ failure = error if isinstance(error, DshPackError) else PackImportError(
1240
1430
  "Import failed unexpectedly",
1241
1431
  stage="commit",
1242
1432
  code="IMPORT_FAILED",
1243
1433
  details={
1244
1434
  "item": "prepared metadata / Profile commit",
1245
1435
  "expected": {"operation": "write prepared metadata and commit the new Profile"},
1246
- "observed": {"error": str(error)},
1436
+ "observed": observed,
1247
1437
  "evidence": {"profilePath": str(plan.profile_path), "metadataPath": str(plan.metadata_path)},
1248
- "impact": "Import failed during commit and was rolled back",
1438
+ "impact": "Import failed; inspect the recorded rollback outcome",
1249
1439
  "error": str(error),
1250
1440
  },
1251
- ) from error
1441
+ )
1442
+ rollback_errors: list[dict[str, Any]] = []
1443
+
1444
+ def compensate(operation: str, path: Path | None, action: Callable[[], None]) -> None:
1445
+ try:
1446
+ action()
1447
+ except OSError as cleanup_error:
1448
+ rollback_errors.append({
1449
+ "operation": operation, "path": str(path) if path is not None else None,
1450
+ "error": str(cleanup_error), "errorType": type(cleanup_error).__name__,
1451
+ "errno": cleanup_error.errno, "winerror": getattr(cleanup_error, "winerror", None),
1452
+ })
1453
+
1454
+ # Portable files are independent cleanup actions: one locked file must
1455
+ # not prevent removing another file created by this Import.
1456
+ for created_path in reversed(data_created):
1457
+ compensate("remove-portable-file", created_path, lambda path=created_path: path.unlink(missing_ok=True))
1458
+ for created_directory in reversed(data_directories):
1459
+ compensate("remove-portable-directory", created_directory, lambda path=created_directory: _cleanup_empty(path))
1460
+ compensate("remove-temporary-profile", temp_profile, lambda: _cleanup(temp_profile))
1461
+ if final_created:
1462
+ compensate("remove-failed-profile", plan.profile_path, lambda: _cleanup(plan.profile_path))
1463
+ if metadata_created:
1464
+ compensate("remove-failed-metadata", plan.metadata_path, lambda: _cleanup(plan.metadata_path))
1465
+ for operation, backup, destination in (
1466
+ ("restore-original-profile", original_profile_backup, plan.profile_path),
1467
+ ("restore-original-metadata", original_metadata_backup, plan.metadata_path),
1468
+ ):
1469
+ if backup is None or not backup.exists():
1470
+ continue
1471
+ if destination.exists():
1472
+ rollback_errors.append({"operation": operation, "path": str(backup),
1473
+ "error": "恢复目标仍被占用,原始备份已保留,未覆盖该路径。", "destination": str(destination)})
1474
+ else:
1475
+ compensate(operation, backup, lambda saved=backup, target=destination: saved.rename(target))
1476
+ if not metadata_parent_existed:
1477
+ compensate("remove-empty-metadata-parent", metadata_parent, lambda: _cleanup_empty(metadata_parent))
1478
+ if not metadata_root_existed:
1479
+ compensate("remove-empty-metadata-root", metadata_root, lambda: _cleanup_empty(metadata_root))
1480
+ if isinstance(failure, PackImportError):
1481
+ retained_temp = temp_profile is not None and temp_profile.exists()
1482
+ original_retained = original_profile_backup is not None and original_profile_backup.exists()
1483
+ original_metadata_retained = original_metadata_backup is not None and original_metadata_backup.exists()
1484
+ failure.details.update({
1485
+ "temporaryProfileStatus": "retained" if retained_temp else "cleaned",
1486
+ "failedProfileStatus": "retained" if final_created and plan.profile_path.exists() else "cleaned",
1487
+ "originalProfileStatus": "restore-failed" if original_retained else "unchanged",
1488
+ "rollback": {
1489
+ "status": "FAIL" if rollback_errors else "PASS", "errors": rollback_errors,
1490
+ "temporaryProfilePath": str(temp_profile) if retained_temp else None,
1491
+ "originalProfileBackup": str(original_profile_backup) if original_retained else None,
1492
+ "originalMetadataBackup": str(original_metadata_backup) if original_metadata_retained else None,
1493
+ },
1494
+ })
1495
+ if rollback_errors:
1496
+ failure.details.update({
1497
+ "canRetry": False,
1498
+ "impact": "导入失败,部分回滚未完成;请保留并检查诊断中列出的残留和备份路径。",
1499
+ "suggestedNextStep": "先检查原始错误与回滚错误,解除占用并核实残留及原 Profile 状态,再手动重新导入。",
1500
+ })
1501
+ if failure is error:
1502
+ raise
1503
+ raise failure from error
1252
1504
 
1253
1505
 
1254
1506
  def delete_profile(
@@ -1344,4 +1596,3 @@ def delete_profile(
1344
1596
  metadata_path=metadata_target,
1345
1597
  metadata_status=metadata_status,
1346
1598
  )
1347
-