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
@@ -91,7 +91,22 @@ class PackImportError(DshPackError):
91
91
  details.get("temporaryProfileStatus", "cleaned"),
92
92
  )
93
93
  temporary_profile_status = details.get("temporaryProfileStatus", failed_profile_status)
94
+ message = str(self)
95
+ rename_access_error = (
96
+ self.stage == "commit" and self.code == "IMPORT_FAILED"
97
+ and isinstance(observed, dict) and observed.get("winerror") in (5, 32)
98
+ and bool(observed.get("filename2"))
99
+ )
100
+ if rename_access_error:
101
+ # 错误 5 不能独自证明占用或 ACL 原因;保留原始 observed 与回滚裁决。
102
+ message = "无法完成 Profile 提交:Windows 拒绝目录改名,文件可能仍被占用,也可能是权限不足。"
103
+ default_next_step = (
104
+ "关闭正在使用目标目录的文件预览、编辑器或终端,确认目录权限,"
105
+ "并先核实诊断中的回滚和残留状态,再手动重新导入。"
106
+ )
107
+ from .diagnostics import envelope_fields
94
108
  return {
109
+ **envelope_fields(operation="import", status="FAIL", operation_id=details.get("operationId")),
95
110
  "code": self.code,
96
111
  "stage": self.stage,
97
112
  "item": item,
@@ -114,7 +129,7 @@ class PackImportError(DshPackError):
114
129
  "severity": details.get("severity", "BLOCKER"),
115
130
  "canContinue": details.get("canContinue", False),
116
131
  "suggestedChecks": details.get("suggestedChecks") or _default_checks(self.stage, self.code),
117
- "message": str(self),
132
+ "message": message,
118
133
  "details": details,
119
134
  }
120
135
 
@@ -24,7 +24,8 @@ from typing import Any, Mapping, Sequence
24
24
 
25
25
  from .errors import DshPackError, ExportError
26
26
  from .pack import PackData, create, sha256_bytes
27
- from .validation import validate_payload_key
27
+ from .validation import validate_payload_key, bundle_patch_paths
28
+ from .bundle import read_bundle_patches, check_bundle_artifact
28
29
 
29
30
 
30
31
  REFERENCE_ONLY = "reference-only"
@@ -83,6 +84,8 @@ class ExportOptions:
83
84
  plugin_options: Mapping[str, PluginExportOptions] = field(default_factory=dict)
84
85
  npm_command: str | None = None
85
86
  include_installation_bundles: bool = False
87
+ export_mode: str = "custom"
88
+ data_selection: tuple[Mapping[str, Any], ...] = ()
86
89
 
87
90
 
88
91
  @dataclass(frozen=True)
@@ -132,6 +135,8 @@ def _string(value: Any, path: str) -> str:
132
135
 
133
136
 
134
137
  def _source_type(specifier: str) -> str:
138
+ if specifier == "unknown":
139
+ return "unknown"
135
140
  for source_type, prefixes in _SOURCE_TYPE_PREFIXES.items():
136
141
  if specifier.startswith(prefixes):
137
142
  return source_type
@@ -140,50 +145,6 @@ def _source_type(specifier: str) -> str:
140
145
  return "registry"
141
146
 
142
147
 
143
- def _repository_specifier(package_manifest: Mapping[str, Any]) -> str | None:
144
- """Return a portable source locator from npm ``repository`` metadata.
145
-
146
- DSH installation-anchor Bundles are not always listed in the Profile's
147
- dependency map. In that case the installed package manifest is the last
148
- authoritative local source record we have. Keep the locator in the Pack
149
- instead of silently falling back to a package name with ``unknown``.
150
- """
151
-
152
- repository = package_manifest.get("repository")
153
- if isinstance(repository, str):
154
- candidate = repository.strip()
155
- elif isinstance(repository, Mapping):
156
- candidate = repository.get("url")
157
- if not isinstance(candidate, str):
158
- return None
159
- candidate = candidate.strip()
160
- else:
161
- return None
162
- if not candidate:
163
- return None
164
- if candidate.startswith(("git:", "git+", "github:", "ssh://", ".git")):
165
- return candidate
166
- if candidate.startswith(("http://", "https://")):
167
- return f"git+{candidate}"
168
- return candidate
169
-
170
-
171
- def _effective_specifier(
172
- package_name: str,
173
- specifier: str,
174
- package_manifest: Mapping[str, Any],
175
- ) -> str:
176
- if specifier != "unknown":
177
- return specifier
178
- inferred = _repository_specifier(package_manifest)
179
- if inferred:
180
- return inferred
181
- raise ExportError(
182
- f"cannot export {package_name}: package source is unavailable; "
183
- "Profile dependency specifier or package.json.repository is required"
184
- )
185
-
186
-
187
148
  @dataclass(frozen=True)
188
149
  class _SemVer:
189
150
  major: int
@@ -348,23 +309,46 @@ def _package_path(profile_dir: Path, package_name: str) -> Path:
348
309
  return profile_dir / "node_modules" / Path(*package_name.split("/"))
349
310
 
350
311
 
351
- def _installed_package(profile_dir: Path, package_name: str) -> tuple[Path, dict[str, Any]]:
352
- # DSH resolves Bundle layers from the installation anchor first and then
353
- # from the Profile. Its healer exposes the installation dependency
354
- # closure at ``$DSH_HOME/profiles/node_modules``. For a Profile under
355
- # ``.../profiles/<name>``, that fallback is the parent node_modules
356
- # directory. Keep the Profile-local lookup first so user-installed
357
- # dependencies retain normal Node nearest-wins semantics.
358
- package_paths = [_package_path(profile_dir, package_name)]
359
- fallback_path = profile_dir.parent / "node_modules" / Path(*package_name.split("/"))
360
- if fallback_path not in package_paths:
361
- package_paths.append(fallback_path)
312
+ def _installation_module_roots(profile_dir: Path) -> list[Path]:
313
+ """返回可读取的安装锚点;桌面 Host 负责物化 ASAR 中选定的包。"""
314
+ roots: list[Path] = []
315
+ configured = os.environ.get("DSH_PACK_RUNTIME_MODULES", "").strip()
316
+ if configured:
317
+ explicit = Path(configured)
318
+ if not explicit.is_absolute() or not explicit.is_dir():
319
+ raise ExportError(
320
+ "DSH_PACK_RUNTIME_MODULES must be an existing absolute node_modules directory; "
321
+ "Desktop ASAR packages must be materialized before Export"
322
+ )
323
+ roots.append(explicit)
324
+ shared = profile_dir.parent / "node_modules"
325
+ if shared not in roots:
326
+ roots.append(shared)
327
+ return roots
328
+
329
+
330
+ def _installed_package(
331
+ profile_dir: Path, package_name: str, *, bundle: bool = False,
332
+ ) -> tuple[Path, dict[str, Any]]:
333
+ # Bundle composition follows DSH's installation-anchor-first lookup.
334
+ # Ordinary dependencies retain the Profile's nearest package precedence.
335
+ local = _package_path(profile_dir, package_name)
336
+ anchors = [root / Path(*package_name.split("/")) for root in _installation_module_roots(profile_dir)]
337
+ package_paths = list(dict.fromkeys([*anchors, local] if bundle else [local, *anchors]))
362
338
  for package_dir in package_paths:
363
339
  package_json = package_dir / "package.json"
364
340
  if package_json.is_file():
365
341
  return package_dir, _read_json_file(package_json, f"installed package {package_name}")
366
342
  searched = ", ".join(str(path / "package.json") for path in package_paths)
367
- raise ExportError(f"installed package manifest is missing; searched: {searched}")
343
+ raise ExportError(
344
+ f"installed package manifest is missing; searched: {searched}. "
345
+ "Desktop ASAR sources must be materialized and exposed through DSH_PACK_RUNTIME_MODULES"
346
+ )
347
+
348
+
349
+ def _runtime_source(profile_dir: Path, package_name: str, package_dir: Path) -> str:
350
+ """记录本次实际选取的目录来源,不能根据 dependency 请求是否存在推断。"""
351
+ return "profile-dependency" if package_dir == _package_path(profile_dir, package_name) else "installation-anchor"
368
352
 
369
353
 
370
354
  def _direct_package_dirs(root: Path) -> list[Path]:
@@ -393,10 +377,8 @@ def _installed_bundle_inventory(profile_dir: Path) -> list[tuple[str, Path, str,
393
377
  for an explicit "include installed Bundles" export.
394
378
  """
395
379
 
396
- roots = (
397
- (profile_dir / "node_modules", "profile"),
398
- (profile_dir.parent / "node_modules", "installation-anchor"),
399
- )
380
+ roots = [(root, "installation-anchor") for root in _installation_module_roots(profile_dir)]
381
+ roots.append((profile_dir / "node_modules", "profile"))
400
382
  found: list[tuple[str, Path, str, dict[str, Any]]] = []
401
383
  names: set[str] = set()
402
384
  for root, location in roots:
@@ -415,8 +397,8 @@ def _installed_bundle_inventory(profile_dir: Path) -> list[tuple[str, Path, str,
415
397
  or not package_name.strip()
416
398
  or not isinstance(package_version, str)
417
399
  or not package_version.strip()
418
- or not isinstance(patch, str)
419
- or not patch.strip()
400
+ or not isinstance(patch, (str, list))
401
+ or not patch
420
402
  or package_name in names
421
403
  ):
422
404
  continue
@@ -463,6 +445,10 @@ def _profile_files(profile_dir: Path, destination: Path) -> dict[str, bytes]:
463
445
  if not path.is_file() or path.is_symlink():
464
446
  continue
465
447
  relative_path = path.relative_to(profile_dir)
448
+ # Import generates this private installation evidence from Pack
449
+ # artifacts. It is machine state, not declarative Profile content.
450
+ if relative_path.parts[0].casefold() == '.dsh-crate':
451
+ continue
466
452
  if any(part in _SKIP_DIRS for part in relative_path.parts):
467
453
  continue
468
454
  if relative_path.name in _SKIP_FILES:
@@ -582,25 +568,15 @@ def _assert_no_duplicate_loader_rows(profile_dir: Path, bundles: Sequence[str])
582
568
  registered_by: dict[str, list[str]] = {}
583
569
  for package_name in bundles:
584
570
  try:
585
- package_dir, package_manifest = _installed_package(profile_dir, package_name)
571
+ package_dir, package_manifest = _installed_package(profile_dir, package_name, bundle=True)
586
572
  except ExportError:
587
573
  continue
588
574
  dsh = package_manifest.get("dsh") or {}
589
575
  bundle_meta = dsh.get("bundle") or {}
590
576
  patch = bundle_meta.get("patch")
591
- if not isinstance(patch, str) or not patch.strip():
592
- continue
593
- try:
594
- patch_relative = _bundle_patch_path(package_dir, package_name, patch)
595
- except ExportError:
596
- continue
597
- patch_path = package_dir.joinpath(*patch_relative.split("/"))
598
- try:
599
- patch_text = patch_path.read_text(encoding="utf-8")
600
- except (OSError, UnicodeDecodeError):
601
- continue
602
- for row_id in _bundle_inserted_loader_ids(patch_text):
603
- registered_by.setdefault(row_id, []).append(package_name)
577
+ for relative, content in read_bundle_patches(package_dir, patch):
578
+ for row_id in _bundle_inserted_loader_ids(content.decode("utf-8")):
579
+ registered_by.setdefault(row_id, []).append(f"{package_name}/{relative}")
604
580
  collisions = sorted(row_id for row_id, names in registered_by.items() if len(names) > 1)
605
581
  if not collisions:
606
582
  return
@@ -613,11 +589,11 @@ def _assert_no_duplicate_loader_rows(profile_dir: Path, bundles: Sequence[str])
613
589
  )
614
590
 
615
591
 
616
- def _validate_tarball(content: bytes, package_name: str, required_file: str | None = None) -> None:
592
+ def _validate_tarball(content: bytes, package_name: str, required_file: str | Sequence[str] | None = None) -> None:
617
593
  from io import BytesIO
618
594
 
619
- required_member = f"package/{required_file}" if required_file else None
620
- found_required = False
595
+ required_members = {f"package/{item}" for item in bundle_patch_paths(required_file)} if required_file else set()
596
+ found_required: set[str] = set()
621
597
  try:
622
598
  with tarfile.open(fileobj=BytesIO(content), mode="r:gz") as archive:
623
599
  for member in archive.getmembers():
@@ -626,11 +602,11 @@ def _validate_tarball(content: bytes, package_name: str, required_file: str | No
626
602
  raise ExportError(f"npm pack artifact contains forbidden runtime path: {package_name}")
627
603
  if member_name.startswith("/") or "/../" in f"/{member_name}/" or member_name.startswith("../"):
628
604
  raise ExportError(f"npm pack artifact contains unsafe path: {package_name}")
629
- if required_member == member_name and member.isfile():
630
- found_required = True
605
+ if member_name in required_members and member.isfile():
606
+ found_required.add(member_name)
631
607
  except (tarfile.TarError, OSError) as error:
632
608
  raise ExportError(f"npm pack did not produce a readable tarball: {package_name}") from error
633
- if required_member and not found_required:
609
+ if required_members - found_required:
634
610
  raise ExportError(f"npm pack artifact is missing Bundle patch file: {package_name}/{required_file}")
635
611
 
636
612
 
@@ -639,7 +615,7 @@ def _npm_pack(
639
615
  package_name: str,
640
616
  npm_command: str,
641
617
  *,
642
- required_file: str | None = None,
618
+ required_file: str | Sequence[str] | None = None,
643
619
  ) -> bytes:
644
620
  with tempfile.TemporaryDirectory(prefix="dsh-pack-npm-") as directory:
645
621
  cache_directory = Path(directory) / "npm-cache"
@@ -688,24 +664,22 @@ def _plugin_lock_entry(
688
664
  bundle_order: int | None,
689
665
  options: PluginExportOptions,
690
666
  npm_command: str,
691
- runtime_source: str | None = None,
692
667
  ) -> tuple[dict[str, Any], dict[str, bytes]]:
693
- package_dir, package_manifest = _installed_package(profile_dir, package_name)
668
+ package_dir, package_manifest = _installed_package(profile_dir, package_name, bundle=bundle_order is not None)
694
669
  installed_name = _string(package_manifest.get("name", package_name), f"{package_name}.package.json.name")
695
670
  if installed_name != package_name:
696
671
  raise ExportError(
697
672
  f"installed package name mismatch: requested {package_name!r}, found {installed_name!r}"
698
673
  )
699
674
  version = _string(package_manifest.get("version"), f"{package_name}.package.json.version")
700
- specifier = _effective_specifier(package_name, specifier, package_manifest)
701
675
  source_type = _source_type(specifier)
702
- if source_type == "registry":
676
+ resolved_runtime_source = _runtime_source(profile_dir, package_name, package_dir)
677
+ if source_type == "registry" and resolved_runtime_source != "installation-anchor":
703
678
  semver_match = _registry_spec_satisfies(specifier, version)
704
679
  if semver_match is False:
705
680
  raise ExportError(
706
681
  f"installed package {package_name}@{version} does not satisfy requested specifier {specifier!r}"
707
682
  )
708
- resolved_runtime_source = runtime_source or ("profile-dependency" if specifier else "installation-anchor")
709
683
  dsh = package_manifest.get("dsh", {})
710
684
  if dsh is None:
711
685
  dsh = {}
@@ -717,19 +691,21 @@ def _plugin_lock_entry(
717
691
  if not isinstance(bundle_meta, dict):
718
692
  raise ExportError(f"{package_name}.package.json.dsh.bundle must be an object")
719
693
  patch = bundle_meta.get("patch")
720
- patch_relative_path: str | None = None
721
- if bundle_order is not None and (not isinstance(patch, str) or not patch.strip()):
722
- raise ExportError(f"Bundle {package_name} is missing dsh.bundle.patch")
694
+ patch_relative_path: str | list[str] | None = None
723
695
  if bundle_order is not None:
724
- patch_relative_path = _bundle_patch_path(package_dir, package_name, patch)
696
+ checked_patches = read_bundle_patches(package_dir, patch)
697
+ paths = [relative for relative, _ in checked_patches]
698
+ patch_relative_path = paths if isinstance(patch, list) else paths[0]
725
699
 
726
700
  entry: dict[str, Any] = {
727
701
  "name": package_name,
728
702
  "required": options.required,
729
703
  "requested": {"specifier": specifier, "sourceType": source_type},
730
704
  "resolved": {
731
- "version": version,
732
- "locator": f"registry:{package_name}@{version}" if source_type == "registry" else specifier,
705
+ # No pnpm importer/lock evidence is read by this exporter. A
706
+ # runtime manifest or repository URL cannot prove resolution.
707
+ "version": "unknown",
708
+ "locator": "unknown",
733
709
  },
734
710
  "runtime": {
735
711
  "version": version,
@@ -754,6 +730,8 @@ def _plugin_lock_entry(
754
730
  npm_command,
755
731
  required_file=patch_relative_path,
756
732
  )
733
+ if bundle_order is not None:
734
+ check_bundle_artifact(content, patch)
757
735
  artifact_file = _artifact_name(package_name, version)
758
736
  artifact_path = f"plugins/{artifact_file}"
759
737
  entry["artifact"] = {
@@ -777,6 +755,8 @@ def export_profile(
777
755
  destination Pack and temporary local npm tarballs used for embedded mode.
778
756
  """
779
757
 
758
+ if options.export_mode not in {"share", "freeze", "custom"}:
759
+ raise ExportError("invalid export mode")
780
760
  profile_dir = Path(profile).resolve()
781
761
  destination_path = Path(destination).resolve()
782
762
  if not profile_dir.is_dir():
@@ -788,16 +768,12 @@ def export_profile(
788
768
  dependencies = _profile_dependencies(profile_manifest)
789
769
  bundles = _profile_bundles(profile_manifest)
790
770
  plugin_options = dict(options.plugin_options)
791
- extra_runtime_sources: dict[str, str] = {}
792
771
  if options.include_installation_bundles:
793
772
  installed_inventory = _installed_bundle_inventory(profile_dir)
794
773
  installed_names = {package_name for package_name, *_ in installed_inventory}
795
- for package_name, _package_dir, location, _package_manifest in installed_inventory:
774
+ for package_name, _package_dir, _location, _package_manifest in installed_inventory:
796
775
  if package_name in plugin_options and package_name not in bundles:
797
776
  bundles.append(package_name)
798
- extra_runtime_sources[package_name] = (
799
- "profile-dependency" if location == "profile" else "installation-anchor"
800
- )
801
777
  else:
802
778
  installed_names = set()
803
779
  plugin_names = list(bundles)
@@ -818,7 +794,7 @@ def export_profile(
818
794
  artifacts: dict[str, bytes] = {}
819
795
  for package_name in plugin_names:
820
796
  specifier = dependencies.get(package_name, "unknown")
821
- selected = plugin_options.get(package_name, PluginExportOptions())
797
+ selected = plugin_options.get(package_name, PluginExportOptions(mode=EMBEDDED if options.export_mode == "freeze" else REFERENCE_ONLY))
822
798
  entry, package_artifacts = _plugin_lock_entry(
823
799
  profile_dir,
824
800
  package_name,
@@ -826,7 +802,6 @@ def export_profile(
826
802
  bundles.index(package_name) if package_name in bundles else None,
827
803
  selected,
828
804
  npm_command,
829
- extra_runtime_sources.get(package_name),
830
805
  )
831
806
  plugins.append(entry)
832
807
  for artifact_name, content in package_artifacts.items():
@@ -843,10 +818,18 @@ def export_profile(
843
818
  "environment": copy.deepcopy(dict(options.environment)),
844
819
  "requiredSecrets": list(required_secrets),
845
820
  }
821
+ from .export_plan import build_plan
822
+ from .portability import collect
823
+ user_data, portable_items = collect(profile_dir, options.data_selection, set(plugin_names))
824
+ plan = build_plan(profile_dir, options=options, portable_items=portable_items)
825
+ multiple_patches = any(isinstance(plugin["bundle"]["patch"], list) for plugin in plugins)
826
+ manifest.update({"schemaVersion": 3 if multiple_patches else 2, "exportMode": options.export_mode,
827
+ "dataInventory": plan["dataInventory"], "completeness": plan["completeness"]})
846
828
  data = PackData(
829
+ user_data=user_data,
847
830
  manifest=manifest,
848
831
  profile_files=profile_files,
849
- plugins_lock={"schemaVersion": 1, "plugins": plugins},
832
+ plugins_lock={"schemaVersion": 2 if multiple_patches else 1, "plugins": plugins},
850
833
  plugin_artifacts=artifacts,
851
834
  )
852
835
  destination_path.parent.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,96 @@
1
+ """Read-only Export preview shared by CLI and Web."""
2
+ from __future__ import annotations
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .errors import ExportError
7
+ from .portability import collect, discover
8
+
9
+
10
+ def build_plan(profile: Path, *, options, portable_items=None) -> dict[str, Any]:
11
+ from .export import (EMBEDDED, REFERENCE_ONLY, PluginExportOptions,
12
+ _installed_package, _profile_bundles, _profile_dependencies,
13
+ _read_json_file, _imported_required_secrets)
14
+ if options.export_mode not in {"share", "freeze", "custom"}:
15
+ raise ExportError("invalid export mode")
16
+ profile = Path(profile).resolve()
17
+ manifest = _read_json_file(profile / "package.json", "Profile package.json")
18
+ dependencies = _profile_dependencies(manifest)
19
+ bundles = _profile_bundles(manifest)
20
+ names = list(dict.fromkeys([*bundles, *dependencies]))
21
+ if options.include_installation_bundles:
22
+ from .export import _installed_bundle_inventory
23
+ for name, *_ in _installed_bundle_inventory(profile):
24
+ if name in options.plugin_options and name not in bundles:
25
+ bundles.append(name)
26
+ if name in options.plugin_options and name not in names:
27
+ names.append(name)
28
+ if not names:
29
+ raise ExportError("Profile has no dependencies or bundles")
30
+ unknown = set(options.plugin_options) - set(names)
31
+ if unknown:
32
+ raise ExportError("plugin selections refer to packages absent from Profile")
33
+ plugins = []
34
+ dependency_requirements = [
35
+ {"name": "Node.js", "version": options.environment.get("node", {}).get("version", "unknown"),
36
+ "kind": "runtime-host", "hostProvided": True, "peer": False, "bundled": False,
37
+ "reason": "Node.js 运行环境不包含在 Crate 中,需要接收方提供并验证。"},
38
+ {"name": "DSH runtime", "version": options.environment.get("dsh", {}).get("version", "unknown"),
39
+ "kind": "runtime-host", "hostProvided": True, "peer": False, "bundled": False,
40
+ "reason": "DSH 运行环境不包含在 Crate 中,需要接收方提供并验证。"},
41
+ ]
42
+ for name in names:
43
+ _, package = _installed_package(profile, name, bundle=name in bundles)
44
+ selected = options.plugin_options.get(name, PluginExportOptions(
45
+ mode=EMBEDDED if options.export_mode == "freeze" else REFERENCE_ONLY))
46
+ plugins.append({"name": name, "version": package.get("version", "unknown"),
47
+ "mode": selected.mode, "required": selected.required})
48
+ bundled = package.get("bundleDependencies", package.get("bundledDependencies", []))
49
+ for field, kind, peer in (("dependencies", "package-dependency", False),
50
+ ("optionalDependencies", "optional-dependency", False),
51
+ ("peerDependencies", "peer-dependency", True)):
52
+ requirements = package.get(field, {})
53
+ if not isinstance(requirements, dict):
54
+ continue
55
+ for dependency, version in requirements.items():
56
+ if dependency in names:
57
+ continue
58
+ is_bundled = not peer and (bundled is True or isinstance(bundled, list) and dependency in bundled)
59
+ reason = ("包声明 bundled dependency;是否包含在实际制品中仍需检查,不能据此认定离线恢复成功。"
60
+ if is_bundled else
61
+ "包声明 peer dependency;需由目标组合满足并真实验证。" if peer else
62
+ "普通安装依赖不等于宿主已提供;需按来源解析、安装并验证。")
63
+ dependency_requirements.append({"name": dependency, "version": version, "requiredBy": name,
64
+ "kind": kind, "hostProvided": False, "peer": peer,
65
+ "bundled": is_bundled, "reason": reason})
66
+ if portable_items is None:
67
+ _, portable_items = collect(profile, options.data_selection, set(names))
68
+ inventory = list(portable_items)
69
+ selected_paths = {item["path"] for item in portable_items}
70
+ if profile.parent.name == "profiles":
71
+ inventory.extend(item for item in discover(profile) if item["path"] not in selected_paths)
72
+ secrets = list(tuple(options.required_secrets) or _imported_required_secrets(profile))
73
+ inventory.extend({"name": name, "classification": "secret-name-only"} for name in secrets)
74
+ inventory.extend([
75
+ {"name": "credentials", "classification": "excluded", "reason": "Secret values never belong in a Crate"},
76
+ {"name": "runtime state", "classification": "excluded", "reason": "node_modules, cache, logs and temporary files are not portable"},
77
+ ])
78
+ references = [p["name"] for p in plugins if p["mode"] == REFERENCE_ONLY]
79
+ unsupported = [item["path"] for item in inventory if item["classification"] == "unsupported"]
80
+ completeness = {
81
+ "embedded": [p["name"] for p in plugins if p["mode"] == EMBEDDED],
82
+ "referenceOnly": references, "secretNameOnly": secrets,
83
+ "excluded": [item.get("path", item.get("name")) for item in inventory if item["classification"] == "excluded"],
84
+ "unsupported": unsupported, "hostDependencies": [
85
+ {"name": row["name"], "version": row["version"]}
86
+ for row in dependency_requirements if row["hostProvided"]
87
+ ],
88
+ "offlineRestorable": "NO" if references else "UNPROVEN",
89
+ "verification": "UNTESTED",
90
+ "reason": "Export is not an offline restore test. Verify the exact artifact in a fresh isolated Home.",
91
+ }
92
+ return {"status": "plan", "readOnly": True, "profile": profile.name,
93
+ "exportMode": options.export_mode, "plugins": plugins,
94
+ "bundleOrder": bundles, "dataInventory": inventory,
95
+ "requiredSecrets": secrets, "dependencyRequirements": dependency_requirements,
96
+ "completeness": completeness}
@@ -0,0 +1,25 @@
1
+ """Installation checks without conflating recorded resolution and runtime."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Mapping
5
+
6
+
7
+ def expected_install_version(plugin: Mapping[str, Any]) -> str | None:
8
+ """Use legacy resolution, or runtime only for an explicit unknown sentinel.
9
+
10
+ Export has no pnpm resolution evidence. Its runtime identity can still
11
+ constrain a supplied artifact or local package without inventing RESOLUTION.
12
+ Missing/invalid legacy fields do not silently acquire a fallback.
13
+ """
14
+ resolved = plugin.get("resolved")
15
+ version = resolved.get("version") if isinstance(resolved, Mapping) else None
16
+ if version == "unknown":
17
+ runtime = plugin.get("runtime")
18
+ version = runtime.get("version") if isinstance(runtime, Mapping) else None
19
+ return version if isinstance(version, str) and version.strip() and version != "unknown" else None
20
+
21
+
22
+ def unknown_source(plugin: Mapping[str, Any]) -> bool:
23
+ requested = plugin.get("requested")
24
+ return (not isinstance(requested, Mapping) or requested.get("sourceType") == "unknown"
25
+ or requested.get("specifier") == "unknown")