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.
- 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 +468 -217
- 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 +343 -194
- 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 +14 -7
- 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
|
@@ -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
|
-
|
|
273
|
-
|
|
274
|
-
requirements[name] =
|
|
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":
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
1053
|
-
#
|
|
1054
|
-
#
|
|
1055
|
-
# and local
|
|
1056
|
-
|
|
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
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1339
|
+
installed_names.add(name)
|
|
1131
1340
|
continue
|
|
1132
1341
|
if options.allow_network_reference_install and name in network_plugins:
|
|
1133
|
-
continue #
|
|
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
|
-
#
|
|
1143
|
-
#
|
|
1144
|
-
#
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
|
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
|
|
1417
|
+
installed_plugins=tuple(installed),
|
|
1207
1418
|
network_installs=tuple(network_installs),
|
|
1208
1419
|
warnings=tuple(warnings),
|
|
1209
1420
|
)
|
|
1210
|
-
except
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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":
|
|
1436
|
+
"observed": observed,
|
|
1247
1437
|
"evidence": {"profilePath": str(plan.profile_path), "metadataPath": str(plan.metadata_path)},
|
|
1248
|
-
"impact": "Import failed
|
|
1438
|
+
"impact": "Import failed; inspect the recorded rollback outcome",
|
|
1249
1439
|
"error": str(error),
|
|
1250
1440
|
},
|
|
1251
|
-
)
|
|
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
|
-
|