dsh-crate-web 0.1.0 → 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.
- package/LICENSE +21 -0
- package/README.md +11 -290
- package/README.zh-CN.md +2 -303
- package/THIRD-PARTY-LICENSE.txt +21 -0
- package/cordis.patch.yml +1 -6
- package/core/dsh_pack/_version.py +1 -0
- package/core/dsh_pack/bundle.py +74 -0
- package/core/dsh_pack/cli.py +81 -18
- package/core/dsh_pack/desktop_verify.py +304 -0
- package/core/dsh_pack/diagnostics.py +1170 -0
- package/core/dsh_pack/errors.py +16 -1
- package/core/dsh_pack/export.py +86 -103
- package/core/dsh_pack/export_plan.py +96 -0
- package/core/dsh_pack/identity.py +25 -0
- package/core/dsh_pack/importer.py +1598 -1347
- package/core/dsh_pack/network.py +5 -4
- package/core/dsh_pack/pack.py +14 -2
- package/core/dsh_pack/portability.py +392 -0
- package/core/dsh_pack/preflight.py +63 -16
- package/core/dsh_pack/troubleshooting.py +94 -0
- package/core/dsh_pack/user_cli.py +16 -0
- package/core/dsh_pack/validation.py +64 -4
- package/core/dsh_pack/verify.py +1485 -1336
- package/core/dsh_pack/windows_verify.py +177 -0
- package/desktop-patch-helper.mjs +140 -0
- package/lib/client.js +292 -102
- package/lib/index.d.ts +7 -1
- package/lib/index.js +1121 -229
- package/package.json +13 -5
- package/restart-helper.mjs +121 -121
- package/runtime/python/LICENSE.txt +676 -676
- package/runtime/python/__install__.json +81 -81
- package/runtime/python/python314._pth +6 -6
- package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
- package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
- package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
- package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
- package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
- package/verify-desktop.mjs +314 -0
package/core/dsh_pack/errors.py
CHANGED
|
@@ -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":
|
|
132
|
+
"message": message,
|
|
118
133
|
"details": details,
|
|
119
134
|
}
|
|
120
135
|
|
package/core/dsh_pack/export.py
CHANGED
|
@@ -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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
620
|
-
found_required =
|
|
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
|
|
630
|
-
found_required
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
732
|
-
|
|
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,
|
|
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")
|