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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -290
  3. package/README.zh-CN.md +2 -303
  4. package/THIRD-PARTY-LICENSE.txt +21 -0
  5. package/cordis.patch.yml +1 -6
  6. package/core/dsh_pack/_version.py +1 -0
  7. package/core/dsh_pack/bundle.py +74 -0
  8. package/core/dsh_pack/cli.py +81 -18
  9. package/core/dsh_pack/desktop_verify.py +304 -0
  10. package/core/dsh_pack/diagnostics.py +1170 -0
  11. package/core/dsh_pack/errors.py +16 -1
  12. package/core/dsh_pack/export.py +86 -103
  13. package/core/dsh_pack/export_plan.py +96 -0
  14. package/core/dsh_pack/identity.py +25 -0
  15. package/core/dsh_pack/importer.py +1598 -1347
  16. package/core/dsh_pack/network.py +5 -4
  17. package/core/dsh_pack/pack.py +14 -2
  18. package/core/dsh_pack/portability.py +392 -0
  19. package/core/dsh_pack/preflight.py +63 -16
  20. package/core/dsh_pack/troubleshooting.py +94 -0
  21. package/core/dsh_pack/user_cli.py +16 -0
  22. package/core/dsh_pack/validation.py +64 -4
  23. package/core/dsh_pack/verify.py +1485 -1336
  24. package/core/dsh_pack/windows_verify.py +177 -0
  25. package/desktop-patch-helper.mjs +140 -0
  26. package/lib/client.js +292 -102
  27. package/lib/index.d.ts +7 -1
  28. package/lib/index.js +1121 -229
  29. package/package.json +13 -5
  30. package/restart-helper.mjs +121 -121
  31. package/runtime/python/LICENSE.txt +676 -676
  32. package/runtime/python/__install__.json +81 -81
  33. package/runtime/python/python314._pth +6 -6
  34. package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
  35. package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
  36. package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
  37. package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
  38. package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
  39. package/verify-desktop.mjs +314 -0
@@ -1,1347 +1,1598 @@
1
- """Phase 4 Pack import into a named, isolated DSH Profile.
2
-
3
- The importer deliberately has no overwrite or merge path. It prepares one new
4
- Profile directory under ``DSH_HOME/profiles`` and keeps Pack metadata outside
5
- the runtime Profile under ``DSH_HOME/.dsh-pack/imports``. Reference-only
6
- plugins are supplied by an explicit local source first. If enabled and no
7
- local source is available, npm resolves recorded registry/GitHub/tarball
8
- metadata inside the temporary Profile; no shell is used and lifecycle scripts
9
- are disabled.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import json
15
- import os
16
- import shutil
17
- import subprocess
18
- import tarfile
19
- import tempfile
20
- import uuid
21
- from dataclasses import dataclass, field, replace
22
- from io import BytesIO
23
- from pathlib import Path
24
- from typing import Any, Callable, Mapping
25
-
26
- from .errors import DshPackError, PackImportError
27
- from .pack import PackData, read, sha256_bytes
28
- from .network import npm_install_specifier
29
- from .preflight import PreflightContext, PreflightResult, inspect_pack
30
- from .validation import validate_archive_path
31
-
32
-
33
- ReferenceInstaller = Callable[[Path, Mapping[str, Any]], None]
34
- _REFERENCE_SOURCE_SKIP_DIRS = {"node_modules", ".git", "cache", "logs"}
35
-
36
-
37
- @dataclass(frozen=True)
38
- class ImportOptions:
39
- """Facts and explicit installation adapters used by one import."""
40
-
41
- current_environment: Mapping[str, Any] = field(default_factory=dict)
42
- available_secrets: set[str] | None = None
43
- reference_plugin_sources: Mapping[str, str | os.PathLike[str]] = field(default_factory=dict)
44
- reference_plugin_versions: Mapping[str, str | None] = field(default_factory=dict)
45
- reference_installer: ReferenceInstaller | None = None
46
- allow_network_reference_install: bool = True
47
- npm_command: str | None = None
48
- network_install_timeout: int = 180
49
- target_profile_name: str | None = None
50
- overwrite_existing_profile: bool = False
51
- overwrite_confirmation: str | None = None
52
-
53
-
54
- @dataclass(frozen=True)
55
- class ImportPlan:
56
- """A read-only plan produced before any target filesystem write."""
57
-
58
- pack_path: Path
59
- dsh_home: Path
60
- requested_profile_name: str
61
- profile_name: str
62
- profile_path: Path
63
- metadata_path: Path
64
- overwrite: bool
65
- target_exists: bool
66
- embedded_plugins: tuple[dict[str, Any], ...]
67
- reference_plugins: tuple[dict[str, Any], ...]
68
- preflight: PreflightResult
69
-
70
- def as_dict(self) -> dict[str, Any]:
71
- return {
72
- "packPath": str(self.pack_path),
73
- "dshHome": str(self.dsh_home),
74
- "requestedProfileName": self.requested_profile_name,
75
- "profileName": self.profile_name,
76
- "profilePath": str(self.profile_path),
77
- "metadataPath": str(self.metadata_path),
78
- "overwrite": self.overwrite,
79
- "targetExists": self.target_exists,
80
- "embeddedPlugins": list(self.embedded_plugins),
81
- "referencePlugins": list(self.reference_plugins),
82
- "preflight": self.preflight.as_dict(),
83
- "decision": "READY_TO_IMPORT" if self.preflight.status == "READY" else "NOT_READY",
84
- }
85
-
86
-
87
- @dataclass(frozen=True)
88
- class ImportResult:
89
- """A successfully prepared new Profile."""
90
-
91
- status: str
92
- profile_path: Path
93
- metadata_path: Path
94
- plan: ImportPlan
95
- installed_plugins: tuple[dict[str, Any], ...]
96
- network_installs: tuple[dict[str, Any], ...] = ()
97
- warnings: tuple[dict[str, Any], ...] = ()
98
-
99
- def as_dict(self) -> dict[str, Any]:
100
- return {
101
- "status": self.status,
102
- "profilePath": str(self.profile_path),
103
- "metadataPath": str(self.metadata_path),
104
- "installedPlugins": list(self.installed_plugins),
105
- "networkInstalls": list(self.network_installs),
106
- "warnings": list(self.warnings),
107
- "plan": self.plan.as_dict(),
108
- }
109
-
110
-
111
- @dataclass(frozen=True)
112
- class DeleteResult:
113
- """Result of an explicitly confirmed Profile deletion.
114
-
115
- ``metadata_status`` reports what happened to the paired Import metadata
116
- directory: ``removed``, ``no-metadata`` (nothing was stored for this
117
- Profile), or ``failed`` (the Profile is deleted but metadata cleanup did
118
- not complete).
119
- """
120
-
121
- status: str
122
- profile_name: str
123
- profile_path: Path
124
- metadata_path: Path | None = None
125
- metadata_status: str = "no-metadata"
126
-
127
- def as_dict(self) -> dict[str, Any]:
128
- return {
129
- "status": self.status,
130
- "profileName": self.profile_name,
131
- "profilePath": str(self.profile_path),
132
- "metadataPath": str(self.metadata_path) if self.metadata_path is not None else None,
133
- "metadataStatus": self.metadata_status,
134
- }
135
-
136
-
137
- def _profile_name(manifest: Mapping[str, Any]) -> str:
138
- profile = manifest.get("profile")
139
- if not isinstance(profile, Mapping) or not isinstance(profile.get("name"), str):
140
- raise PackImportError(
141
- "Pack manifest has no valid profile name",
142
- stage="planning",
143
- code="PROFILE_NAME_MISSING",
144
- )
145
- return profile["name"]
146
-
147
-
148
- def _validate_profile_name(name: str, *, label: str = "Profile name") -> str:
149
- if (
150
- not isinstance(name, str)
151
- or not name.strip()
152
- or name in {".", ".."}
153
- or "/" in name
154
- or "\\" in name
155
- or "\x00" in name
156
- ):
157
- raise PackImportError(
158
- f"{label} must be a single safe directory name",
159
- stage="planning",
160
- code="PROFILE_NAME_INVALID",
161
- details={"item": name},
162
- )
163
- return name
164
-
165
-
166
- def _select_profile_name(
167
- dsh_home: Path,
168
- requested_name: str,
169
- *,
170
- target_name: str | None = None,
171
- overwrite: bool = False,
172
- ) -> str:
173
- """Choose a new Profile directory without ever reusing an occupied name.
174
-
175
- Import has no overwrite or merge mode. When a Pack's original Profile name
176
- is already present in the destination DSH_HOME, planning fails with
177
- ``PROFILE_EXISTS`` instead of allocating a suffix: the original Profile is
178
- left untouched and no files are written. The metadata directory is checked
179
- as well so a stale prepared record cannot be mistaken for an unused target.
180
- ``overwrite=True`` is a separate explicit path that requires the target to
181
- already exist and refuses symlink or non-directory targets.
182
- """
183
-
184
- requested_name = _validate_profile_name(requested_name, label="Pack Profile name")
185
- selected_name = _validate_profile_name(target_name or requested_name, label="Target Profile name")
186
- profiles_root = dsh_home / "profiles"
187
- metadata_root = dsh_home / ".dsh-pack" / "imports"
188
-
189
- def occupied(name: str) -> bool:
190
- return (profiles_root / name).exists() or (metadata_root / name).exists()
191
-
192
- if overwrite:
193
- profile_path = profiles_root / selected_name
194
- if profile_path.is_symlink() or (profile_path.exists() and not profile_path.is_dir()):
195
- raise PackImportError(
196
- f"target Profile is not a safe directory: {profile_path}",
197
- stage="planning",
198
- code="PROFILE_TARGET_INVALID",
199
- details={"profilePath": str(profile_path)},
200
- )
201
- if not profile_path.exists():
202
- raise PackImportError(
203
- f"target Profile does not exist for overwrite: {profile_path}",
204
- stage="planning",
205
- code="PROFILE_TARGET_MISSING",
206
- details={"profilePath": str(profile_path)},
207
- )
208
- return selected_name
209
- if occupied(selected_name):
210
- raise PackImportError(
211
- f"target Profile already exists: {profiles_root / selected_name}",
212
- stage="planning",
213
- code="PROFILE_EXISTS",
214
- details={"profilePath": str(profiles_root / selected_name), "requestedProfileName": selected_name},
215
- )
216
- return selected_name
217
-
218
-
219
- def _package_install_path(profile_path: Path, package_name: str) -> Path:
220
- if not package_name or "\\" in package_name or package_name.startswith("/"):
221
- raise PackImportError(
222
- f"unsafe package name: {package_name!r}",
223
- stage="planning",
224
- code="PACKAGE_NAME_INVALID",
225
- )
226
- parts = package_name.split("/")
227
- if any(not part or part in {".", ".."} for part in parts):
228
- raise PackImportError(
229
- f"unsafe package name: {package_name!r}",
230
- stage="planning",
231
- code="PACKAGE_NAME_INVALID",
232
- )
233
- return profile_path / "node_modules" / Path(*parts)
234
-
235
-
236
- def _package_manifest(package_path: Path, *, stage: str) -> dict[str, Any]:
237
- manifest_path = package_path / "package.json"
238
- try:
239
- value = json.loads(manifest_path.read_text(encoding="utf-8"))
240
- except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
241
- raise PackImportError(
242
- f"cannot read installed package manifest: {manifest_path}",
243
- stage=stage,
244
- code="PACKAGE_MANIFEST_INVALID",
245
- ) from error
246
- if not isinstance(value, dict) or not isinstance(value.get("name"), str) or not isinstance(value.get("version"), str):
247
- raise PackImportError(
248
- f"installed package manifest lacks name/version: {manifest_path}",
249
- stage=stage,
250
- code="PACKAGE_MANIFEST_INVALID",
251
- )
252
- return value
253
-
254
-
255
- def _reference_inventory(options: ImportOptions) -> dict[str, str | None]:
256
- inventory = dict(options.reference_plugin_versions)
257
- for name, source in options.reference_plugin_sources.items():
258
- manifest = _package_manifest(Path(source), stage="planning")
259
- inventory[name] = manifest["version"]
260
- return inventory
261
-
262
-
263
- def _reference_plugin_requirements(data: PackData) -> dict[str, str | None]:
264
- requirements: dict[str, str | None] = {}
265
- for plugin in data.plugins_lock.get("plugins", []):
266
- if not isinstance(plugin, Mapping):
267
- continue
268
- artifact = plugin.get("artifact")
269
- name = plugin.get("name")
270
- if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only" or not isinstance(name, str):
271
- continue
272
- resolved = plugin.get("resolved")
273
- version = resolved.get("version") if isinstance(resolved, Mapping) else None
274
- requirements[name] = version if isinstance(version, str) else None
275
- return requirements
276
-
277
-
278
- def _reference_search_roots(dsh_home: Path) -> tuple[Path, ...]:
279
- """Return local DSH package roots used for automatic reference discovery.
280
-
281
- Only shared installation roots are eligible: the per-home anchor
282
- (``profiles/node_modules``) and the home-level ``node_modules``. Per-Profile
283
- package directories are excluded because npm hoists a Profile's transitive
284
- dependencies to that Profile's own ``node_modules`` top level. Copying a bare
285
- package out of there cannot resolve those dependencies in the new Profile (a
286
- sibling Profile is not an ancestor in Node's resolution walk), which yields an
287
- installed Profile that fails to boot. Explicit ``reference_plugin_sources``
288
- overrides remain the way to reuse a package from a specific location.
289
- """
290
-
291
- profiles_root = dsh_home / "profiles"
292
- roots: list[Path] = [profiles_root / "node_modules", dsh_home / "node_modules"]
293
- return tuple(dict.fromkeys(roots))
294
-
295
-
296
- def _auto_reference_sources(
297
- data: PackData,
298
- dsh_home: Path,
299
- options: ImportOptions,
300
- ) -> ImportOptions:
301
- """Resolve reference-only packages already installed in this DSH_HOME.
302
-
303
- A Pack never stores machine-specific absolute paths. During Import, use
304
- DSH's shared installation anchor first, then existing Profile package
305
- directories, and keep an explicitly supplied source as the override.
306
- This is local discovery only; it does not contact a registry or run a
307
- package manager.
308
- """
309
-
310
- sources = dict(options.reference_plugin_sources)
311
- requirements = _reference_plugin_requirements(data)
312
- roots = _reference_search_roots(dsh_home)
313
- for name, expected_version in requirements.items():
314
- if name in sources:
315
- continue
316
- candidates: list[tuple[Path, str | None]] = []
317
- for root in roots:
318
- package_path = root.joinpath(*name.split("/"))
319
- manifest_path = package_path / "package.json"
320
- if not manifest_path.is_file():
321
- continue
322
- try:
323
- manifest = _package_manifest(package_path, stage="planning")
324
- except PackImportError:
325
- candidates.append((package_path, None))
326
- continue
327
- if manifest.get("name") != name:
328
- continue
329
- candidates.append((package_path, manifest.get("version")))
330
-
331
- if not candidates:
332
- continue
333
- exact = next(
334
- (path for path, version in candidates if expected_version is not None and version == expected_version),
335
- None,
336
- )
337
- sources[name] = exact or candidates[0][0]
338
-
339
- if sources == options.reference_plugin_sources:
340
- return options
341
- return replace(options, reference_plugin_sources=sources)
342
-
343
-
344
- def _network_reference_plugins(data: PackData, options: ImportOptions) -> tuple[dict[str, Any], ...]:
345
- """Return reference-only entries that still need a network install."""
346
-
347
- result: list[dict[str, Any]] = []
348
- for raw_plugin in data.plugins_lock.get("plugins", []):
349
- if not isinstance(raw_plugin, Mapping):
350
- continue
351
- artifact = raw_plugin.get("artifact")
352
- name = raw_plugin.get("name")
353
- if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only" or not isinstance(name, str):
354
- continue
355
- if name in options.reference_plugin_sources or name in options.reference_plugin_versions:
356
- continue
357
- if npm_install_specifier(raw_plugin) is None:
358
- continue
359
- result.append(dict(raw_plugin))
360
- return tuple(result)
361
-
362
-
363
- def _npm_command(configured: str | None) -> str:
364
- if configured:
365
- return configured
366
- if os.name == "nt":
367
- return shutil.which("npm.cmd") or shutil.which("npm") or "npm.cmd"
368
- return shutil.which("npm") or "npm"
369
-
370
-
371
- def _network_error_output(value: str, limit: int = 4000) -> str:
372
- """Keep command diagnostics bounded and avoid dumping a whole npm log."""
373
-
374
- value = value.strip()
375
- return value[-limit:] if len(value) > limit else value
376
-
377
-
378
- def _install_network_reference(profile_path: Path, plugin: Mapping[str, Any], options: ImportOptions) -> dict[str, Any]:
379
- """Install one recorded network source into the temporary Profile.
380
-
381
- The command is argv-based and lifecycle scripts are disabled. The package
382
- manager therefore owns registry/GitHub/tarball resolution while Pack keeps
383
- ownership of target isolation and exact package identity verification.
384
- """
385
-
386
- name = str(plugin.get("name"))
387
- specifier = npm_install_specifier(plugin)
388
- if specifier is None:
389
- raise PackImportError(
390
- f"no supported network source is recorded for {name}",
391
- stage="network-install",
392
- code="NETWORK_SOURCE_UNSUPPORTED",
393
- details={"plugin": name},
394
- )
395
- command = [
396
- _npm_command(options.npm_command),
397
- "install",
398
- "--ignore-scripts",
399
- # DSH host packages are peer dependencies supplied by the runtime
400
- # installation anchor. Do not make npm fetch a second host tree from
401
- # the public registry while installing a reference-only plugin.
402
- "--legacy-peer-deps",
403
- "--no-save",
404
- "--no-package-lock",
405
- "--no-audit",
406
- "--no-fund",
407
- "--prefix",
408
- str(profile_path),
409
- "--",
410
- specifier,
411
- ]
412
- try:
413
- completed = subprocess.run(
414
- command,
415
- cwd=profile_path,
416
- capture_output=True,
417
- text=True,
418
- check=False,
419
- timeout=max(1, int(options.network_install_timeout)),
420
- )
421
- except FileNotFoundError as error:
422
- raise PackImportError(
423
- f"npm command is unavailable while installing {name}",
424
- stage="network-install",
425
- code="NETWORK_INSTALL_FAILED",
426
- details={"plugin": name, "command": command[0], "source": specifier, "error": str(error)},
427
- ) from error
428
- except subprocess.TimeoutExpired as error:
429
- raise PackImportError(
430
- f"network install timed out for {name}",
431
- stage="network-install",
432
- code="NETWORK_INSTALL_TIMEOUT",
433
- details={"plugin": name, "source": specifier, "timeoutSeconds": options.network_install_timeout},
434
- ) from error
435
- except OSError as error:
436
- raise PackImportError(
437
- f"cannot start npm while installing {name}",
438
- stage="network-install",
439
- code="NETWORK_INSTALL_FAILED",
440
- details={"plugin": name, "command": command[0], "source": specifier, "error": str(error)},
441
- ) from error
442
- if completed.returncode != 0:
443
- raise PackImportError(
444
- f"npm install failed for {name} with exit code {completed.returncode}",
445
- stage="network-install",
446
- code="NETWORK_INSTALL_FAILED",
447
- details={
448
- "plugin": name,
449
- "source": specifier,
450
- "command": command,
451
- "exitCode": completed.returncode,
452
- "stdout": _network_error_output(completed.stdout),
453
- "stderr": _network_error_output(completed.stderr),
454
- },
455
- )
456
- target = _package_install_path(profile_path, name)
457
- if not (target / "package.json").is_file():
458
- raise PackImportError(
459
- f"npm install completed but package is missing: {name}",
460
- stage="network-install",
461
- code="NETWORK_PACKAGE_MISSING",
462
- details={"plugin": name, "source": specifier, "target": str(target)},
463
- )
464
- return {
465
- "name": name,
466
- "source": specifier,
467
- "resolver": "npm",
468
- "scripts": "ignored",
469
- "stdout": _network_error_output(completed.stdout),
470
- }
471
-
472
-
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
- def _context(options: ImportOptions) -> PreflightContext:
604
- return PreflightContext(
605
- current_environment=dict(options.current_environment),
606
- available_secrets=set() if options.available_secrets is None else set(options.available_secrets),
607
- available_plugins=_reference_inventory(options),
608
- allow_network_reference_install=options.allow_network_reference_install,
609
- )
610
-
611
-
612
- def _plugin_rows(lock: Mapping[str, Any], mode: str) -> tuple[dict[str, Any], ...]:
613
- result: list[dict[str, Any]] = []
614
- for plugin in lock.get("plugins", []):
615
- if not isinstance(plugin, Mapping):
616
- continue
617
- artifact = plugin.get("artifact")
618
- if not isinstance(artifact, Mapping) or artifact.get("mode") != mode:
619
- continue
620
- resolved = plugin.get("resolved")
621
- result.append(
622
- {
623
- "name": plugin.get("name"),
624
- "version": resolved.get("version") if isinstance(resolved, Mapping) else None,
625
- "required": plugin.get("required") is True,
626
- "artifact": dict(artifact),
627
- }
628
- )
629
- return tuple(result)
630
-
631
-
632
- def create_import_plan(
633
- source: str | os.PathLike[str],
634
- dsh_home: str | os.PathLike[str],
635
- *,
636
- options: ImportOptions | None = None,
637
- ) -> ImportPlan:
638
- """Run Preflight and create a no-write Import Plan."""
639
-
640
- options = options or ImportOptions()
641
- source_path = Path(source).resolve()
642
- home_path = Path(dsh_home).resolve()
643
- try:
644
- pack_data = read(source_path)
645
- except DshPackError:
646
- pack_data = None
647
- if pack_data is not None:
648
- options = _auto_reference_sources(pack_data, home_path, options)
649
- try:
650
- preflight = inspect_pack(source_path, context=_context(options))
651
- except (DshPackError, OSError) as error:
652
- if isinstance(error, PackImportError):
653
- raise
654
- raise PackImportError(
655
- str(error),
656
- stage="preflight",
657
- code="PREFLIGHT_ERROR",
658
- ) from error
659
- if preflight.blockers:
660
- raise PackImportError(
661
- "Preflight blocked Import",
662
- stage="preflight",
663
- code="PREFLIGHT_BLOCKED",
664
- details={"findings": [finding.as_dict() for finding in preflight.blockers]},
665
- )
666
- try:
667
- data = pack_data if pack_data is not None else read(source_path)
668
- except DshPackError as error:
669
- raise PackImportError(str(error), stage="preflight", code="PACK_READ_ERROR") from error
670
-
671
- requested_profile_name = _profile_name(data.manifest)
672
- profile_name = _select_profile_name(
673
- home_path,
674
- requested_profile_name,
675
- target_name=options.target_profile_name,
676
- overwrite=options.overwrite_existing_profile,
677
- )
678
- profile_path = home_path / "profiles" / profile_name
679
- metadata_path = home_path / ".dsh-pack" / "imports" / profile_name
680
- target_exists = profile_path.exists() or metadata_path.exists()
681
- # _select_profile_name checks both locations. Keep these guards for a
682
- # clear diagnostic if the filesystem changes between selection and return.
683
- if target_exists and not options.overwrite_existing_profile:
684
- raise PackImportError(
685
- f"no safe target Profile name is available for {requested_profile_name!r}",
686
- stage="planning",
687
- code="PROFILE_NAME_UNAVAILABLE",
688
- details={"requestedProfileName": requested_profile_name, "profilePath": str(profile_path), "metadataPath": str(metadata_path)},
689
- )
690
- if "package.json" not in data.profile_files:
691
- raise PackImportError(
692
- "Pack profile/ is missing package.json",
693
- stage="planning",
694
- code="PROFILE_CONFIG_MISSING",
695
- )
696
-
697
- return ImportPlan(
698
- pack_path=source_path,
699
- dsh_home=home_path,
700
- requested_profile_name=requested_profile_name,
701
- profile_name=profile_name,
702
- profile_path=profile_path,
703
- metadata_path=metadata_path,
704
- overwrite=options.overwrite_existing_profile,
705
- target_exists=target_exists,
706
- embedded_plugins=_plugin_rows(data.plugins_lock, "embedded"),
707
- reference_plugins=_plugin_rows(data.plugins_lock, "reference-only"),
708
- preflight=preflight,
709
- )
710
-
711
-
712
- def _write_file(root: Path, relative: str, content: bytes) -> None:
713
- archive_path = validate_archive_path(f"profile/{relative}", expected_root="profile")
714
- relative_path = archive_path[len("profile/"):]
715
- destination = root.joinpath(*relative_path.split("/"))
716
- destination.parent.mkdir(parents=True, exist_ok=True)
717
- if destination.exists() or destination.is_symlink():
718
- raise PackImportError(
719
- f"Profile target file already exists: {destination}",
720
- stage="configuration",
721
- code="PROFILE_FILE_EXISTS",
722
- )
723
- destination.write_bytes(content)
724
-
725
-
726
- def _extract_embedded_artifact(profile_path: Path, plugin: Mapping[str, Any], content: bytes) -> None:
727
- name = str(plugin["name"])
728
- target = _package_install_path(profile_path, name)
729
- if target.exists():
730
- raise PackImportError(
731
- f"embedded plugin target already exists: {target}",
732
- stage="embedded-install",
733
- code="PLUGIN_TARGET_EXISTS",
734
- )
735
- target.mkdir(parents=True, exist_ok=False)
736
- seen: set[str] = set()
737
- try:
738
- with tarfile.open(fileobj=BytesIO(content), mode="r:gz") as archive:
739
- for member in archive.getmembers():
740
- raw_name = member.name.replace("\\", "/")
741
- if raw_name.endswith("/"):
742
- raw_name = raw_name[:-1]
743
- if raw_name == "package":
744
- continue
745
- canonical = validate_archive_path(raw_name)
746
- if not canonical.startswith("package/"):
747
- raise PackImportError(
748
- f"artifact member is outside package/: {member.name!r}",
749
- stage="embedded-install",
750
- code="ARTIFACT_PATH_INVALID",
751
- )
752
- relative = canonical[len("package/"):]
753
- if not relative or relative in seen:
754
- raise PackImportError(
755
- f"duplicate artifact member: {member.name!r}",
756
- stage="embedded-install",
757
- code="ARTIFACT_MEMBER_DUPLICATE",
758
- )
759
- seen.add(relative)
760
- destination = target.joinpath(*relative.split("/"))
761
- if member.issym():
762
- raise PackImportError(
763
- f"unsupported artifact symlink member: {member.name!r}",
764
- stage="embedded-install",
765
- code="ARTIFACT_MEMBER_TYPE_INVALID",
766
- )
767
- if member.isdir():
768
- destination.mkdir(parents=True, exist_ok=True)
769
- continue
770
- if member.islnk():
771
- # npm tarballs can contain hardlink members. Resolve the
772
- # target inside the same archive and materialize a regular
773
- # file so the installed package is a plain directory tree.
774
- content_bytes = _read_artifact_hardlink(archive, member)
775
- destination.parent.mkdir(parents=True, exist_ok=True)
776
- destination.write_bytes(content_bytes)
777
- continue
778
- if not member.isfile():
779
- raise PackImportError(
780
- f"unsupported artifact member type: {member.name!r}",
781
- stage="embedded-install",
782
- code="ARTIFACT_MEMBER_TYPE_INVALID",
783
- )
784
- extracted = archive.extractfile(member)
785
- if extracted is None:
786
- raise PackImportError(
787
- f"cannot read artifact member: {member.name!r}",
788
- stage="embedded-install",
789
- code="ARTIFACT_MEMBER_READ_ERROR",
790
- )
791
- destination.parent.mkdir(parents=True, exist_ok=True)
792
- destination.write_bytes(extracted.read())
793
- except PackImportError:
794
- raise
795
- except DshPackError as error:
796
- raise PackImportError(
797
- f"embedded artifact is unsafe for {name}",
798
- stage="embedded-install",
799
- code="ARTIFACT_PATH_INVALID",
800
- details={"error": str(error)},
801
- ) from error
802
- except (OSError, tarfile.TarError) as error:
803
- raise PackImportError(
804
- f"cannot unpack embedded artifact for {name}",
805
- stage="embedded-install",
806
- code="ARTIFACT_UNPACK_FAILED",
807
- details={
808
- "item": name,
809
- "expected": {"artifact": "valid gzip tar archive", "package": name},
810
- "observed": {"error": str(error)},
811
- "evidence": {"package": name, "stage": "embedded-install"},
812
- "error": str(error),
813
- },
814
- ) from error
815
-
816
-
817
-
818
- def _read_artifact_hardlink(archive: tarfile.TarFile, member: tarfile.TarInfo) -> bytes:
819
- """Read the file content a tar hardlink member points to.
820
-
821
- npm preserves hardlinks in packed tarballs. The link target is resolved
822
- within the same archive only; symlinks, directory targets, missing
823
- members, and cycles are rejected.
824
- """
825
-
826
- resolved: set[str] = set()
827
- current = member.linkname.replace("\\", "/")
828
- for _ in range(16):
829
- canonical = validate_archive_path(current)
830
- if not canonical.startswith("package/"):
831
- raise PackImportError(
832
- f"artifact hardlink target is outside package/: {member.linkname!r}",
833
- stage="embedded-install",
834
- code="ARTIFACT_PATH_INVALID",
835
- )
836
- if canonical in resolved:
837
- raise PackImportError(
838
- f"artifact hardlink target cycle: {member.linkname!r}",
839
- stage="embedded-install",
840
- code="ARTIFACT_MEMBER_TYPE_INVALID",
841
- )
842
- resolved.add(canonical)
843
- try:
844
- target_member = archive.getmember(canonical)
845
- except KeyError:
846
- raise PackImportError(
847
- f"artifact hardlink target is missing: {canonical!r}",
848
- stage="embedded-install",
849
- code="ARTIFACT_MEMBER_READ_ERROR",
850
- ) from None
851
- if target_member.issym() or target_member.isdir():
852
- raise PackImportError(
853
- f"artifact hardlink target is not a regular file: {canonical!r}",
854
- stage="embedded-install",
855
- code="ARTIFACT_MEMBER_TYPE_INVALID",
856
- )
857
- if target_member.islnk():
858
- current = target_member.linkname.replace("\\", "/")
859
- continue
860
- extracted = archive.extractfile(target_member)
861
- if extracted is None:
862
- raise PackImportError(
863
- f"cannot read artifact hardlink target: {canonical!r}",
864
- stage="embedded-install",
865
- code="ARTIFACT_MEMBER_READ_ERROR",
866
- )
867
- return extracted.read()
868
- raise PackImportError(
869
- f"artifact hardlink chain is too long: {member.name!r}",
870
- stage="embedded-install",
871
- code="ARTIFACT_MEMBER_TYPE_INVALID",
872
- )
873
-
874
-
875
- def _copy_reference_source(profile_path: Path, plugin: Mapping[str, Any], source: Path) -> None:
876
- name = str(plugin["name"])
877
- target = _package_install_path(profile_path, name)
878
- if not source.is_dir():
879
- raise PackImportError(
880
- f"reference plugin source is not a directory: {source}",
881
- stage="reference-install",
882
- code="PLUGIN_SOURCE_MISSING",
883
- )
884
- source = source.resolve()
885
- if target.exists():
886
- raise PackImportError(
887
- f"reference plugin target already exists: {target}",
888
- stage="reference-install",
889
- code="PLUGIN_TARGET_EXISTS",
890
- )
891
- target.mkdir(parents=True, exist_ok=False)
892
-
893
- def is_directory_link(path: Path) -> bool:
894
- junction_check = getattr(path, "is_junction", None)
895
- return path.is_symlink() or (callable(junction_check) and junction_check())
896
-
897
- for current_root, directory_names, file_names in os.walk(source, topdown=True, followlinks=False):
898
- current_path = Path(current_root)
899
- retained_directories: list[str] = []
900
- for directory_name in directory_names:
901
- directory_path = current_path / directory_name
902
- relative_parts = directory_path.relative_to(source).parts
903
- if any(part in _REFERENCE_SOURCE_SKIP_DIRS for part in relative_parts):
904
- continue
905
- if is_directory_link(directory_path):
906
- raise PackImportError(
907
- f"reference plugin source contains symlink or junction: {directory_path}",
908
- stage="reference-install",
909
- code="PLUGIN_SOURCE_UNSAFE",
910
- )
911
- retained_directories.append(directory_name)
912
- directory_names[:] = retained_directories
913
-
914
- for file_name in file_names:
915
- path = current_path / file_name
916
- relative = path.relative_to(source).as_posix()
917
- validate_archive_path(f"package/{relative}", expected_root="package")
918
- if path.is_symlink():
919
- raise PackImportError(
920
- f"reference plugin source contains symlink: {path}",
921
- stage="reference-install",
922
- code="PLUGIN_SOURCE_UNSAFE",
923
- )
924
- destination = target / Path(*path.relative_to(source).parts)
925
- destination.parent.mkdir(parents=True, exist_ok=True)
926
- shutil.copy2(path, destination)
927
-
928
-
929
- def _verify_identity(profile_path: Path, plugin: Mapping[str, Any]) -> dict[str, Any]:
930
- name = str(plugin["name"])
931
- resolved = plugin.get("resolved")
932
- expected_version = resolved.get("version") if isinstance(resolved, Mapping) else None
933
- installed = _package_manifest(_package_install_path(profile_path, name), stage="version-confirmation")
934
- if installed["name"] != name or installed["version"] != expected_version:
935
- raise PackImportError(
936
- f"installed {name}@{installed.get('version')} does not match Pack {name}@{expected_version}",
937
- stage="version-confirmation",
938
- code="PLUGIN_VERSION_MISMATCH",
939
- details={
940
- "plugin": name,
941
- "expected": {"name": name, "version": expected_version},
942
- "observed": {"name": installed.get("name"), "version": installed.get("version")},
943
- },
944
- )
945
- return {"name": name, "version": installed["version"], "mode": plugin["artifact"]["mode"]}
946
-
947
-
948
- def _metadata_payload(plan: ImportPlan, data: PackData, installed: tuple[dict[str, Any], ...], pack_digest: str) -> dict[str, Any]:
949
- return {
950
- "schemaVersion": 1,
951
- "status": "prepared",
952
- "pack": {"sha256": pack_digest, "path": str(plan.pack_path)},
953
- "profile": {"name": plan.profile_name, "path": str(plan.profile_path)},
954
- "overwrite": plan.overwrite,
955
- "plugins": list(installed),
956
- "requiredSecrets": list(data.manifest.get("requiredSecrets", [])),
957
- }
958
-
959
-
960
- def _cleanup(path: Path | None) -> None:
961
- if path is None or not path.exists():
962
- return
963
- if path.is_dir():
964
- shutil.rmtree(path)
965
- else:
966
- path.unlink()
967
-
968
-
969
- def _cleanup_empty(path: Path | None) -> None:
970
- """Remove a newly-created directory only when it is empty."""
971
-
972
- if path is None or not path.is_dir():
973
- return
974
- try:
975
- next(path.iterdir())
976
- except StopIteration:
977
- path.rmdir()
978
-
979
-
980
- def import_pack(
981
- source: str | os.PathLike[str],
982
- dsh_home: str | os.PathLike[str],
983
- *,
984
- options: ImportOptions | None = None,
985
- ) -> ImportResult:
986
- """Import one Pack as a prepared Profile with an explicit safe mode.
987
-
988
- The default mode creates a new Profile and refuses duplicate names. An
989
- overwrite is accepted only when the caller supplies the exact confirmation
990
- token produced after a user-facing confirmation dialog.
991
- """
992
-
993
- options = options or ImportOptions()
994
- if options.overwrite_existing_profile and options.overwrite_confirmation != "OVERWRITE":
995
- raise PackImportError(
996
- "overwriting an existing Profile requires explicit confirmation",
997
- stage="planning",
998
- code="OVERWRITE_CONFIRMATION_REQUIRED",
999
- details={
1000
- "expected": "OVERWRITE",
1001
- "observed": "confirmation missing or invalid",
1002
- "impact": "No Profile was changed.",
1003
- "suggestedNextStep": "Confirm the destructive overwrite in the user interface and retry.",
1004
- },
1005
- )
1006
-
1007
-
1008
- if options.overwrite_confirmation is not None and not options.overwrite_existing_profile:
1009
- raise PackImportError(
1010
- "overwrite confirmation cannot be used without overwrite mode",
1011
- stage="planning",
1012
- code="OVERWRITE_MODE_REQUIRED",
1013
- details={"impact": "No Profile was changed."},
1014
- )
1015
- source_path = Path(source).resolve()
1016
- home_path = Path(dsh_home).resolve()
1017
- try:
1018
- pack_data = read(source_path)
1019
- except DshPackError:
1020
- pack_data = None
1021
- if pack_data is not None:
1022
- options = _auto_reference_sources(pack_data, home_path, options)
1023
- plan = create_import_plan(source_path, home_path, options=options)
1024
- try:
1025
- data = read(plan.pack_path)
1026
- except DshPackError as error:
1027
- raise PackImportError(str(error), stage="preflight", code="PACK_READ_ERROR") from error
1028
-
1029
- profiles_root = plan.dsh_home / "profiles"
1030
- temp_profile: Path | None = None
1031
- final_created = False
1032
- metadata_created = False
1033
- original_profile_backup: Path | None = None
1034
- original_metadata_backup: Path | None = None
1035
- backup_token = uuid.uuid4().hex
1036
- network_plugins = {str(plugin.get("name")): plugin for plugin in _network_reference_plugins(data, options)}
1037
- network_installs: list[dict[str, Any]] = []
1038
- warnings: list[dict[str, Any]] = []
1039
- metadata_parent = plan.metadata_path.parent
1040
- metadata_root = metadata_parent.parent
1041
- metadata_parent_existed = metadata_parent.exists()
1042
- metadata_root_existed = metadata_root.exists()
1043
- try:
1044
- profiles_root.mkdir(parents=True, exist_ok=True)
1045
- temp_profile = Path(tempfile.mkdtemp(prefix=f".{plan.profile_name}.dsh-pack-", dir=profiles_root))
1046
- for relative, content in data.profile_files.items():
1047
- _write_file(temp_profile, relative, content)
1048
-
1049
- installed: dict[str, dict[str, Any]] = {}
1050
- lock_plugins = [plugin for plugin in data.plugins_lock.get("plugins", []) if isinstance(plugin, Mapping)]
1051
-
1052
- # Network reference installs run first: npm reconciles the whole
1053
- # Profile tree at the temporary prefix and prunes packages that are not
1054
- # part of its resolution. Manually placed packages (embedded extracts
1055
- # and local reference copies) must therefore be written only after every
1056
- # npm operation has finished, or npm would delete them.
1057
- for plugin in lock_plugins:
1058
- artifact = plugin.get("artifact")
1059
- if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only":
1060
- continue
1061
- name = str(plugin["name"])
1062
- if options.reference_installer is not None or name in options.reference_plugin_sources:
1063
- continue # handled by the local-source pass below
1064
- if not (options.allow_network_reference_install and name in network_plugins):
1065
- continue
1066
- try:
1067
- network_installs.append(_install_network_reference(temp_profile, plugin, options))
1068
- except PackImportError as error:
1069
- if plugin.get("required") is True:
1070
- raise
1071
- warnings.append(error.as_dict())
1072
- continue
1073
- installed[name] = _verify_identity(temp_profile, plugin)
1074
-
1075
- # Embedded artifacts are self-contained package tarballs. Collect
1076
- # them, install the eligible dependency closure through the package
1077
- # manager BEFORE any manual placement so a reconciling npm run cannot
1078
- # prune manually placed packages, then extract whatever npm did not
1079
- # place (workspace-protocol packages whose closure is supplied by the
1080
- # DSH installation anchor).
1081
- embedded_artifacts: list[tuple[dict[str, Any], bytes]] = []
1082
- for plugin in lock_plugins:
1083
- artifact = plugin.get("artifact")
1084
- if not isinstance(artifact, Mapping) or artifact.get("mode") != "embedded":
1085
- continue
1086
- name = str(plugin["name"])
1087
- path = artifact.get("path")
1088
- if not isinstance(path, str):
1089
- raise PackImportError(
1090
- f"embedded artifact path missing for {name}",
1091
- stage="embedded-install",
1092
- code="ARTIFACT_PATH_MISSING",
1093
- )
1094
- content = data.plugin_artifacts.get(path[len("plugins/"):])
1095
- if content is None:
1096
- raise PackImportError(
1097
- f"embedded artifact missing for {name}: {path}",
1098
- stage="embedded-install",
1099
- code="REQUIRED_ARTIFACT_MISSING",
1100
- )
1101
- embedded_artifacts.append((plugin, content))
1102
- _install_embedded_dependencies(temp_profile, embedded_artifacts, options)
1103
- for plugin, _content in embedded_artifacts:
1104
- name = str(plugin["name"])
1105
- if not _package_install_path(temp_profile, name).exists():
1106
- _extract_embedded_artifact(temp_profile, plugin, _content)
1107
- installed[name] = _verify_identity(temp_profile, plugin)
1108
-
1109
- for plugin in lock_plugins:
1110
- artifact = plugin.get("artifact")
1111
- if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only":
1112
- continue
1113
- name = str(plugin["name"])
1114
- if options.reference_installer is not None:
1115
- try:
1116
- options.reference_installer(temp_profile, plugin)
1117
- except PackImportError:
1118
- raise
1119
- except Exception as error:
1120
- raise PackImportError(
1121
- f"reference-only plugin installation failed: {name}",
1122
- stage="reference-install",
1123
- code="PLUGIN_INSTALL_FAILED",
1124
- details={"plugin": name, "error": str(error)},
1125
- ) from error
1126
- installed[name] = _verify_identity(temp_profile, plugin)
1127
- continue
1128
- if name in options.reference_plugin_sources:
1129
- _copy_reference_source(temp_profile, plugin, Path(options.reference_plugin_sources[name]))
1130
- installed[name] = _verify_identity(temp_profile, plugin)
1131
- continue
1132
- if options.allow_network_reference_install and name in network_plugins:
1133
- continue # already installed and verified in the network pass
1134
- if plugin.get("required") is True:
1135
- raise PackImportError(
1136
- f"required reference-only plugin was not installed: {name}",
1137
- stage="reference-install",
1138
- code="PLUGIN_MISSING",
1139
- details={"plugin": name},
1140
- )
1141
-
1142
- # Re-verify every installed package after the final install pass so a
1143
- # package-manager reconciliation that pruned a manually placed package
1144
- # is caught here instead of surfacing as a Profile that cannot boot.
1145
- for plugin in lock_plugins:
1146
- artifact = plugin.get("artifact")
1147
- if not isinstance(artifact, Mapping) or artifact.get("mode") not in ("embedded", "reference-only"):
1148
- continue
1149
- name = str(plugin["name"])
1150
- if name not in installed:
1151
- continue
1152
- installed[name] = _verify_identity(temp_profile, plugin)
1153
-
1154
- pack_digest = sha256_bytes(plan.pack_path.read_bytes())
1155
- final_profile = plan.profile_path
1156
- if final_profile.exists():
1157
- if not plan.overwrite:
1158
- raise PackImportError(
1159
- f"target Profile appeared during Import: {final_profile}",
1160
- stage="commit",
1161
- code="PROFILE_EXISTS",
1162
- )
1163
- original_profile_backup = profiles_root / f".{plan.profile_name}.dsh-pack-backup-{backup_token}"
1164
- final_profile.rename(original_profile_backup)
1165
- if plan.metadata_path.exists():
1166
- if not plan.overwrite:
1167
- raise PackImportError(
1168
- f"target Import metadata appeared during Import: {plan.metadata_path}",
1169
- stage="commit",
1170
- code="PROFILE_EXISTS",
1171
- )
1172
- original_metadata_backup = metadata_parent / f".{plan.profile_name}.dsh-pack-backup-{backup_token}"
1173
- plan.metadata_path.rename(original_metadata_backup)
1174
- temp_profile.rename(final_profile)
1175
- temp_profile = None
1176
- final_created = True
1177
-
1178
- metadata_parent.mkdir(parents=True, exist_ok=True)
1179
- plan.metadata_path.mkdir(parents=False, exist_ok=False)
1180
- metadata_created = True
1181
- (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
- (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
- (plan.metadata_path / "prepared.json").write_bytes(
1184
- json.dumps(_metadata_payload(plan, data, tuple(installed.values()), pack_digest), ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + b"\n"
1185
- )
1186
- for backup in (original_profile_backup, original_metadata_backup):
1187
- if backup is None:
1188
- continue
1189
- try:
1190
- _cleanup(backup)
1191
- except OSError as error:
1192
- warnings.append(
1193
- {
1194
- "code": "OVERWRITE_BACKUP_RETAINED",
1195
- "stage": "commit",
1196
- "item": str(backup),
1197
- "message": "previous Profile data was retained as a recovery backup",
1198
- "details": {"path": str(backup), "error": str(error)},
1199
- }
1200
- )
1201
- return ImportResult(
1202
- status="prepared",
1203
- profile_path=final_profile,
1204
- metadata_path=plan.metadata_path,
1205
- plan=plan,
1206
- installed_plugins=tuple(installed.values()),
1207
- network_installs=tuple(network_installs),
1208
- warnings=tuple(warnings),
1209
- )
1210
- except PackImportError:
1211
- _cleanup(temp_profile)
1212
- if final_created:
1213
- _cleanup(plan.profile_path)
1214
- if metadata_created:
1215
- _cleanup(plan.metadata_path)
1216
- if original_profile_backup is not None and original_profile_backup.exists() and not plan.profile_path.exists():
1217
- original_profile_backup.rename(plan.profile_path)
1218
- if original_metadata_backup is not None and original_metadata_backup.exists() and not plan.metadata_path.exists():
1219
- original_metadata_backup.rename(plan.metadata_path)
1220
- if not metadata_parent_existed:
1221
- _cleanup_empty(metadata_parent)
1222
- if not metadata_root_existed:
1223
- _cleanup_empty(metadata_root)
1224
- raise
1225
- except (OSError, tarfile.TarError, ValueError) as error:
1226
- _cleanup(temp_profile)
1227
- if final_created:
1228
- _cleanup(plan.profile_path)
1229
- if metadata_created:
1230
- _cleanup(plan.metadata_path)
1231
- if original_profile_backup is not None and original_profile_backup.exists() and not plan.profile_path.exists():
1232
- original_profile_backup.rename(plan.profile_path)
1233
- if original_metadata_backup is not None and original_metadata_backup.exists() and not plan.metadata_path.exists():
1234
- original_metadata_backup.rename(plan.metadata_path)
1235
- if not metadata_parent_existed:
1236
- _cleanup_empty(metadata_parent)
1237
- if not metadata_root_existed:
1238
- _cleanup_empty(metadata_root)
1239
- raise PackImportError(
1240
- "Import failed unexpectedly",
1241
- stage="commit",
1242
- code="IMPORT_FAILED",
1243
- details={
1244
- "item": "prepared metadata / Profile commit",
1245
- "expected": {"operation": "write prepared metadata and commit the new Profile"},
1246
- "observed": {"error": str(error)},
1247
- "evidence": {"profilePath": str(plan.profile_path), "metadataPath": str(plan.metadata_path)},
1248
- "impact": "Import failed during commit and was rolled back",
1249
- "error": str(error),
1250
- },
1251
- ) from error
1252
-
1253
-
1254
- def delete_profile(
1255
- dsh_home: str | os.PathLike[str],
1256
- profile_name: str,
1257
- *,
1258
- confirmation: str | None = None,
1259
- ) -> DeleteResult:
1260
- """Delete exactly one Profile and its Import metadata after explicit confirmation.
1261
-
1262
- Deleting a Profile also removes ``DSH_HOME/.dsh-pack/imports/<name>`` so
1263
- the name is not left permanently reserved by a stale prepared record. If
1264
- metadata cleanup fails, the Profile itself stays deleted and the failure is
1265
- reported through ``DeleteResult.metadata_status``.
1266
- """
1267
-
1268
- if confirmation != "DELETE":
1269
- raise PackImportError(
1270
- "deleting a Profile requires explicit confirmation",
1271
- stage="planning",
1272
- code="DELETE_CONFIRMATION_REQUIRED",
1273
- details={
1274
- "expected": "DELETE",
1275
- "observed": "confirmation missing or invalid",
1276
- "impact": "No Profile was changed.",
1277
- "suggestedNextStep": "Confirm the destructive deletion in the user interface and retry.",
1278
- },
1279
- )
1280
- safe_name = _validate_profile_name(profile_name, label="Target Profile name")
1281
- home = Path(dsh_home).resolve()
1282
- profiles_root = home / "profiles"
1283
- target = profiles_root / safe_name
1284
- if target.is_symlink() or (target.exists() and not target.is_dir()):
1285
- raise PackImportError(
1286
- f"target Profile is not a safe directory: {target}",
1287
- stage="planning",
1288
- code="PROFILE_TARGET_INVALID",
1289
- details={"profilePath": str(target)},
1290
- )
1291
- if not target.exists():
1292
- raise PackImportError(
1293
- f"target Profile does not exist: {target}",
1294
- stage="planning",
1295
- code="PROFILE_NOT_FOUND",
1296
- details={"profilePath": str(target)},
1297
- )
1298
- metadata_target = home / ".dsh-pack" / "imports" / safe_name
1299
- if metadata_target.is_symlink() or (metadata_target.exists() and not metadata_target.is_dir()):
1300
- raise PackImportError(
1301
- f"Import metadata for the target Profile is not a safe directory: {metadata_target}",
1302
- stage="planning",
1303
- code="PROFILE_TARGET_INVALID",
1304
- details={"metadataPath": str(metadata_target)},
1305
- )
1306
- backup = profiles_root / f".{safe_name}.dsh-pack-delete-{uuid.uuid4().hex}"
1307
- try:
1308
- target.rename(backup)
1309
- shutil.rmtree(backup)
1310
- except OSError as error:
1311
- if backup.exists() and not target.exists():
1312
- try:
1313
- backup.rename(target)
1314
- except OSError:
1315
- pass
1316
- raise PackImportError(
1317
- f"failed to delete Profile: {target}",
1318
- stage="delete",
1319
- code="PROFILE_DELETE_FAILED",
1320
- details={
1321
- "profilePath": str(target),
1322
- "error": str(error),
1323
- "originalProfileStatus": "restored" if target.exists() else "unknown",
1324
- },
1325
- ) from error
1326
- metadata_status = "no-metadata"
1327
- if metadata_target.exists():
1328
- metadata_backup = metadata_target.parent / f".{safe_name}.dsh-pack-delete-{uuid.uuid4().hex}"
1329
- try:
1330
- metadata_target.rename(metadata_backup)
1331
- shutil.rmtree(metadata_backup)
1332
- metadata_status = "removed"
1333
- except OSError as error:
1334
- metadata_status = "failed"
1335
- if metadata_backup.exists() and not metadata_target.exists():
1336
- try:
1337
- metadata_backup.rename(metadata_target)
1338
- except OSError:
1339
- pass
1340
- return DeleteResult(
1341
- status="deleted",
1342
- profile_name=safe_name,
1343
- profile_path=target,
1344
- metadata_path=metadata_target,
1345
- metadata_status=metadata_status,
1346
- )
1347
-
1
+ """Phase 4 Pack import into a named, isolated DSH Profile.
2
+
3
+ The importer deliberately has no overwrite or merge path. It prepares one new
4
+ Profile directory under ``DSH_HOME/profiles`` and keeps Pack metadata outside
5
+ the runtime Profile under ``DSH_HOME/.dsh-pack/imports``. Reference-only
6
+ plugins are supplied by an explicit local source first. If enabled and no
7
+ local source is available, npm resolves recorded registry/GitHub/tarball
8
+ metadata inside the temporary Profile; no shell is used and lifecycle scripts
9
+ are disabled.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import base64
16
+ import os
17
+ import posixpath
18
+ import re
19
+ import shutil
20
+ import subprocess
21
+ import tarfile
22
+ import tempfile
23
+ import uuid
24
+ from dataclasses import dataclass, field, replace
25
+ from io import BytesIO
26
+ from pathlib import Path
27
+ from typing import Any, Callable, Mapping
28
+
29
+ from .errors import DshPackError, PackImportError
30
+ from .pack import PackData, read, sha256_bytes
31
+ from .network import npm_install_specifier
32
+ from .identity import expected_install_version, unknown_source
33
+ from .preflight import PreflightContext, PreflightResult, inspect_pack
34
+ from .validation import validate_archive_path, bundle_patch_paths
35
+ from .bundle import check_patch_declaration, read_bundle_patches
36
+
37
+
38
+ ReferenceInstaller = Callable[[Path, Mapping[str, Any]], None]
39
+ _REFERENCE_SOURCE_SKIP_DIRS = {"node_modules", ".git", "cache", "logs"}
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ImportOptions:
44
+ """Facts and explicit installation adapters used by one import."""
45
+
46
+ current_environment: Mapping[str, Any] = field(default_factory=dict)
47
+ available_secrets: set[str] | None = None
48
+ reference_plugin_sources: Mapping[str, str | os.PathLike[str]] = field(default_factory=dict)
49
+ reference_plugin_versions: Mapping[str, str | None] = field(default_factory=dict)
50
+ reference_installer: ReferenceInstaller | None = None
51
+ allow_network_reference_install: bool = True
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
56
+ network_install_timeout: int = 180
57
+ target_profile_name: str | None = None
58
+ overwrite_existing_profile: bool = False
59
+ overwrite_confirmation: str | None = None
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ImportPlan:
64
+ """A read-only plan produced before any target filesystem write."""
65
+
66
+ pack_path: Path
67
+ dsh_home: Path
68
+ requested_profile_name: str
69
+ profile_name: str
70
+ profile_path: Path
71
+ metadata_path: Path
72
+ overwrite: bool
73
+ target_exists: bool
74
+ embedded_plugins: tuple[dict[str, Any], ...]
75
+ reference_plugins: tuple[dict[str, Any], ...]
76
+ preflight: PreflightResult
77
+ portable_data: tuple[str, ...] = ()
78
+ host_provided_dependencies: tuple[dict[str, Any], ...] = ()
79
+
80
+ def as_dict(self) -> dict[str, Any]:
81
+ return {
82
+ "packPath": str(self.pack_path),
83
+ "dshHome": str(self.dsh_home),
84
+ "requestedProfileName": self.requested_profile_name,
85
+ "profileName": self.profile_name,
86
+ "profilePath": str(self.profile_path),
87
+ "metadataPath": str(self.metadata_path),
88
+ "overwrite": self.overwrite,
89
+ "targetExists": self.target_exists,
90
+ "embeddedPlugins": list(self.embedded_plugins),
91
+ "referencePlugins": list(self.reference_plugins),
92
+ "preflight": self.preflight.as_dict(),
93
+ "portableData": list(self.portable_data),
94
+ "hostProvidedDependencies": list(self.host_provided_dependencies),
95
+ "decision": "READY_TO_IMPORT" if self.preflight.status == "READY" else "NOT_READY",
96
+ }
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ImportResult:
101
+ """A successfully prepared new Profile."""
102
+
103
+ status: str
104
+ profile_path: Path
105
+ metadata_path: Path
106
+ plan: ImportPlan
107
+ installed_plugins: tuple[dict[str, Any], ...]
108
+ network_installs: tuple[dict[str, Any], ...] = ()
109
+ warnings: tuple[dict[str, Any], ...] = ()
110
+
111
+ def as_dict(self) -> dict[str, Any]:
112
+ return {
113
+ "status": self.status,
114
+ "profilePath": str(self.profile_path),
115
+ "metadataPath": str(self.metadata_path),
116
+ "installedPlugins": list(self.installed_plugins),
117
+ "networkInstalls": list(self.network_installs),
118
+ "warnings": list(self.warnings),
119
+ "plan": self.plan.as_dict(),
120
+ }
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class DeleteResult:
125
+ """Result of an explicitly confirmed Profile deletion.
126
+
127
+ ``metadata_status`` reports what happened to the paired Import metadata
128
+ directory: ``removed``, ``no-metadata`` (nothing was stored for this
129
+ Profile), or ``failed`` (the Profile is deleted but metadata cleanup did
130
+ not complete).
131
+ """
132
+
133
+ status: str
134
+ profile_name: str
135
+ profile_path: Path
136
+ metadata_path: Path | None = None
137
+ metadata_status: str = "no-metadata"
138
+
139
+ def as_dict(self) -> dict[str, Any]:
140
+ return {
141
+ "status": self.status,
142
+ "profileName": self.profile_name,
143
+ "profilePath": str(self.profile_path),
144
+ "metadataPath": str(self.metadata_path) if self.metadata_path is not None else None,
145
+ "metadataStatus": self.metadata_status,
146
+ }
147
+
148
+
149
+ def _profile_name(manifest: Mapping[str, Any]) -> str:
150
+ profile = manifest.get("profile")
151
+ if not isinstance(profile, Mapping) or not isinstance(profile.get("name"), str):
152
+ raise PackImportError(
153
+ "Pack manifest has no valid profile name",
154
+ stage="planning",
155
+ code="PROFILE_NAME_MISSING",
156
+ )
157
+ return profile["name"]
158
+
159
+
160
+ def _validate_profile_name(name: str, *, label: str = "Profile name") -> str:
161
+ if (
162
+ not isinstance(name, str)
163
+ or not name.strip()
164
+ or name in {".", ".."}
165
+ or "/" in name
166
+ or "\\" in name
167
+ or "\x00" in name
168
+ ):
169
+ raise PackImportError(
170
+ f"{label} must be a single safe directory name",
171
+ stage="planning",
172
+ code="PROFILE_NAME_INVALID",
173
+ details={"item": name},
174
+ )
175
+ return name
176
+
177
+
178
+ def _select_profile_name(
179
+ dsh_home: Path,
180
+ requested_name: str,
181
+ *,
182
+ target_name: str | None = None,
183
+ overwrite: bool = False,
184
+ ) -> str:
185
+ """Choose a new Profile directory without ever reusing an occupied name.
186
+
187
+ Import has no overwrite or merge mode. When a Pack's original Profile name
188
+ is already present in the destination DSH_HOME, planning fails with
189
+ ``PROFILE_EXISTS`` instead of allocating a suffix: the original Profile is
190
+ left untouched and no files are written. The metadata directory is checked
191
+ as well so a stale prepared record cannot be mistaken for an unused target.
192
+ ``overwrite=True`` is a separate explicit path that requires the target to
193
+ already exist and refuses symlink or non-directory targets.
194
+ """
195
+
196
+ requested_name = _validate_profile_name(requested_name, label="Pack Profile name")
197
+ selected_name = _validate_profile_name(target_name or requested_name, label="Target Profile name")
198
+ profiles_root = dsh_home / "profiles"
199
+ metadata_root = dsh_home / ".dsh-pack" / "imports"
200
+
201
+ def occupied(name: str) -> bool:
202
+ return (profiles_root / name).exists() or (metadata_root / name).exists()
203
+
204
+ if overwrite:
205
+ profile_path = profiles_root / selected_name
206
+ if profile_path.is_symlink() or (profile_path.exists() and not profile_path.is_dir()):
207
+ raise PackImportError(
208
+ f"target Profile is not a safe directory: {profile_path}",
209
+ stage="planning",
210
+ code="PROFILE_TARGET_INVALID",
211
+ details={"profilePath": str(profile_path)},
212
+ )
213
+ if not profile_path.exists():
214
+ raise PackImportError(
215
+ f"target Profile does not exist for overwrite: {profile_path}",
216
+ stage="planning",
217
+ code="PROFILE_TARGET_MISSING",
218
+ details={"profilePath": str(profile_path)},
219
+ )
220
+ return selected_name
221
+ if occupied(selected_name):
222
+ raise PackImportError(
223
+ f"target Profile already exists: {profiles_root / selected_name}",
224
+ stage="planning",
225
+ code="PROFILE_EXISTS",
226
+ details={"profilePath": str(profiles_root / selected_name), "requestedProfileName": selected_name},
227
+ )
228
+ return selected_name
229
+
230
+
231
+ def _package_install_path(profile_path: Path, package_name: str) -> Path:
232
+ if not package_name or "\\" in package_name or package_name.startswith("/"):
233
+ raise PackImportError(
234
+ f"unsafe package name: {package_name!r}",
235
+ stage="planning",
236
+ code="PACKAGE_NAME_INVALID",
237
+ )
238
+ parts = package_name.split("/")
239
+ if any(not part or part in {".", ".."} for part in parts):
240
+ raise PackImportError(
241
+ f"unsafe package name: {package_name!r}",
242
+ stage="planning",
243
+ code="PACKAGE_NAME_INVALID",
244
+ )
245
+ return profile_path / "node_modules" / Path(*parts)
246
+
247
+
248
+ def _package_manifest(package_path: Path, *, stage: str) -> dict[str, Any]:
249
+ manifest_path = package_path / "package.json"
250
+ try:
251
+ value = json.loads(manifest_path.read_text(encoding="utf-8"))
252
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
253
+ raise PackImportError(
254
+ f"cannot read installed package manifest: {manifest_path}",
255
+ stage=stage,
256
+ code="PACKAGE_MANIFEST_INVALID",
257
+ ) from error
258
+ if not isinstance(value, dict) or not isinstance(value.get("name"), str) or not isinstance(value.get("version"), str):
259
+ raise PackImportError(
260
+ f"installed package manifest lacks name/version: {manifest_path}",
261
+ stage=stage,
262
+ code="PACKAGE_MANIFEST_INVALID",
263
+ )
264
+ return value
265
+
266
+
267
+ def _reference_inventory(options: ImportOptions) -> dict[str, str | None]:
268
+ inventory = dict(options.reference_plugin_versions)
269
+ for name, source in options.reference_plugin_sources.items():
270
+ manifest = _package_manifest(Path(source), stage="planning")
271
+ inventory[name] = manifest["version"]
272
+ return inventory
273
+
274
+
275
+ def _reference_plugin_requirements(data: PackData) -> dict[str, str | None]:
276
+ requirements: dict[str, str | None] = {}
277
+ for plugin in data.plugins_lock.get("plugins", []):
278
+ if not isinstance(plugin, Mapping):
279
+ continue
280
+ artifact = plugin.get("artifact")
281
+ name = plugin.get("name")
282
+ if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only" or not isinstance(name, str):
283
+ continue
284
+ if unknown_source(plugin):
285
+ continue # Unknown source requires an explicit, checked local path.
286
+ requirements[name] = expected_install_version(plugin)
287
+ return requirements
288
+
289
+
290
+ def _reference_search_roots(dsh_home: Path) -> tuple[Path, ...]:
291
+ """Return local DSH package roots used for automatic reference discovery.
292
+
293
+ Only shared installation roots are eligible: the per-home anchor
294
+ (``profiles/node_modules``) and the home-level ``node_modules``. Per-Profile
295
+ package directories are excluded because npm hoists a Profile's transitive
296
+ dependencies to that Profile's own ``node_modules`` top level. Copying a bare
297
+ package out of there cannot resolve those dependencies in the new Profile (a
298
+ sibling Profile is not an ancestor in Node's resolution walk), which yields an
299
+ installed Profile that fails to boot. Explicit ``reference_plugin_sources``
300
+ overrides remain the way to reuse a package from a specific location.
301
+ """
302
+
303
+ profiles_root = dsh_home / "profiles"
304
+ roots: list[Path] = [profiles_root / "node_modules", dsh_home / "node_modules"]
305
+ return tuple(dict.fromkeys(roots))
306
+
307
+
308
+ def _auto_reference_sources(
309
+ data: PackData,
310
+ dsh_home: Path,
311
+ options: ImportOptions,
312
+ ) -> ImportOptions:
313
+ """Resolve reference-only packages already installed in this DSH_HOME.
314
+
315
+ A Pack never stores machine-specific absolute paths. During Import, use
316
+ DSH's shared installation anchor first, then existing Profile package
317
+ directories, and keep an explicitly supplied source as the override.
318
+ This is local discovery only; it does not contact a registry or run a
319
+ package manager.
320
+ """
321
+
322
+ sources = dict(options.reference_plugin_sources)
323
+ requirements = _reference_plugin_requirements(data)
324
+ roots = _reference_search_roots(dsh_home)
325
+ for name, expected_version in requirements.items():
326
+ if name in sources:
327
+ continue
328
+ candidates: list[tuple[Path, str | None]] = []
329
+ for root in roots:
330
+ package_path = root.joinpath(*name.split("/"))
331
+ manifest_path = package_path / "package.json"
332
+ if not manifest_path.is_file():
333
+ continue
334
+ try:
335
+ manifest = _package_manifest(package_path, stage="planning")
336
+ except PackImportError:
337
+ candidates.append((package_path, None))
338
+ continue
339
+ if manifest.get("name") != name:
340
+ continue
341
+ candidates.append((package_path, manifest.get("version")))
342
+
343
+ if not candidates:
344
+ continue
345
+ exact = next(
346
+ (path for path, version in candidates if expected_version is not None and version == expected_version),
347
+ None,
348
+ )
349
+ sources[name] = exact or candidates[0][0]
350
+
351
+ if sources == options.reference_plugin_sources:
352
+ return options
353
+ return replace(options, reference_plugin_sources=sources)
354
+
355
+
356
+ def _network_reference_plugins(data: PackData, options: ImportOptions) -> tuple[dict[str, Any], ...]:
357
+ """Return reference-only entries that still need a network install."""
358
+
359
+ result: list[dict[str, Any]] = []
360
+ for raw_plugin in data.plugins_lock.get("plugins", []):
361
+ if not isinstance(raw_plugin, Mapping):
362
+ continue
363
+ artifact = raw_plugin.get("artifact")
364
+ name = raw_plugin.get("name")
365
+ if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only" or not isinstance(name, str):
366
+ continue
367
+ if name in options.reference_plugin_sources or name in options.reference_plugin_versions:
368
+ continue
369
+ if npm_install_specifier(raw_plugin) is None:
370
+ continue
371
+ result.append(dict(raw_plugin))
372
+ return tuple(result)
373
+
374
+
375
+ def _npm_command(configured: str | None) -> str:
376
+ if configured:
377
+ return configured
378
+ if os.name == "nt":
379
+ return shutil.which("npm.cmd") or shutil.which("npm") or "npm.cmd"
380
+ return shutil.which("npm") or "npm"
381
+
382
+
383
+ def _network_error_output(value: str, limit: int = 4000) -> str:
384
+ """Keep command diagnostics bounded and avoid dumping a whole npm log."""
385
+
386
+ value = value.strip()
387
+ return value[-limit:] if len(value) > limit else value
388
+
389
+
390
+ def _install_network_reference(profile_path: Path, plugin: Mapping[str, Any], options: ImportOptions) -> dict[str, Any]:
391
+ """Install one recorded network source into the temporary Profile.
392
+
393
+ The command is argv-based and lifecycle scripts are disabled. The package
394
+ manager therefore owns registry/GitHub/tarball resolution while Pack keeps
395
+ ownership of target isolation and exact package identity verification.
396
+ """
397
+
398
+ name = str(plugin.get("name"))
399
+ specifier = npm_install_specifier(plugin)
400
+ if specifier is None:
401
+ raise PackImportError(
402
+ f"no supported network source is recorded for {name}",
403
+ stage="network-install",
404
+ code="NETWORK_SOURCE_UNSUPPORTED",
405
+ details={"plugin": name},
406
+ )
407
+ command = [
408
+ _npm_command(options.npm_command),
409
+ "install",
410
+ "--ignore-scripts",
411
+ # DSH host packages are peer dependencies supplied by the runtime
412
+ # installation anchor. Do not make npm fetch a second host tree from
413
+ # the public registry while installing a reference-only plugin.
414
+ "--legacy-peer-deps",
415
+ "--no-save",
416
+ "--no-package-lock",
417
+ "--no-audit",
418
+ "--no-fund",
419
+ "--prefix",
420
+ str(profile_path),
421
+ "--",
422
+ specifier,
423
+ ]
424
+ try:
425
+ completed = subprocess.run(
426
+ command,
427
+ cwd=profile_path,
428
+ capture_output=True,
429
+ text=True,
430
+ check=False,
431
+ timeout=max(1, int(options.network_install_timeout)),
432
+ )
433
+ except FileNotFoundError as error:
434
+ raise PackImportError(
435
+ f"npm command is unavailable while installing {name}",
436
+ stage="network-install",
437
+ code="NETWORK_INSTALL_FAILED",
438
+ details={"plugin": name, "command": command[0], "source": specifier, "error": str(error)},
439
+ ) from error
440
+ except subprocess.TimeoutExpired as error:
441
+ raise PackImportError(
442
+ f"network install timed out for {name}",
443
+ stage="network-install",
444
+ code="NETWORK_INSTALL_TIMEOUT",
445
+ details={"plugin": name, "source": specifier, "timeoutSeconds": options.network_install_timeout},
446
+ ) from error
447
+ except OSError as error:
448
+ raise PackImportError(
449
+ f"cannot start npm while installing {name}",
450
+ stage="network-install",
451
+ code="NETWORK_INSTALL_FAILED",
452
+ details={"plugin": name, "command": command[0], "source": specifier, "error": str(error)},
453
+ ) from error
454
+ if completed.returncode != 0:
455
+ raise PackImportError(
456
+ f"npm install failed for {name} with exit code {completed.returncode}",
457
+ stage="network-install",
458
+ code="NETWORK_INSTALL_FAILED",
459
+ details={
460
+ "plugin": name,
461
+ "source": specifier,
462
+ "command": command,
463
+ "exitCode": completed.returncode,
464
+ "stdout": _network_error_output(completed.stdout),
465
+ "stderr": _network_error_output(completed.stderr),
466
+ },
467
+ )
468
+ target = _package_install_path(profile_path, name)
469
+ if not (target / "package.json").is_file():
470
+ raise PackImportError(
471
+ f"npm install completed but package is missing: {name}",
472
+ stage="network-install",
473
+ code="NETWORK_PACKAGE_MISSING",
474
+ details={"plugin": name, "source": specifier, "target": str(target)},
475
+ )
476
+ return {
477
+ "name": name,
478
+ "source": specifier,
479
+ "resolver": "npm",
480
+ "scripts": "ignored",
481
+ "stdout": _network_error_output(completed.stdout),
482
+ }
483
+
484
+
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"]
491
+ return PreflightContext(
492
+ current_environment=dict(options.current_environment),
493
+ available_secrets=set() if options.available_secrets is None else set(options.available_secrets),
494
+ available_plugins=_reference_inventory(options),
495
+ allow_network_reference_install=options.allow_network_reference_install,
496
+ verified_local_plugins=verified_local_plugins,
497
+ )
498
+
499
+
500
+ def _plugin_rows(lock: Mapping[str, Any], mode: str) -> tuple[dict[str, Any], ...]:
501
+ result: list[dict[str, Any]] = []
502
+ for plugin in lock.get("plugins", []):
503
+ if not isinstance(plugin, Mapping):
504
+ continue
505
+ artifact = plugin.get("artifact")
506
+ if not isinstance(artifact, Mapping) or artifact.get("mode") != mode:
507
+ continue
508
+ result.append(
509
+ {
510
+ "name": plugin.get("name"),
511
+ "version": expected_install_version(plugin),
512
+ "required": plugin.get("required") is True,
513
+ "artifact": dict(artifact),
514
+ }
515
+ )
516
+ return tuple(result)
517
+
518
+
519
+ def create_import_plan(
520
+ source: str | os.PathLike[str],
521
+ dsh_home: str | os.PathLike[str],
522
+ *,
523
+ options: ImportOptions | None = None,
524
+ ) -> ImportPlan:
525
+ """Run Preflight and create a no-write Import Plan."""
526
+
527
+ options = options or ImportOptions()
528
+ source_path = Path(source).resolve()
529
+ home_path = Path(dsh_home).resolve()
530
+ try:
531
+ pack_data = read(source_path)
532
+ except DshPackError:
533
+ pack_data = None
534
+ if pack_data is not None:
535
+ options = _auto_reference_sources(pack_data, home_path, options)
536
+ try:
537
+ preflight = inspect_pack(source_path, context=_context(options))
538
+ except (DshPackError, OSError) as error:
539
+ if isinstance(error, PackImportError):
540
+ raise
541
+ raise PackImportError(
542
+ str(error),
543
+ stage="preflight",
544
+ code="PREFLIGHT_ERROR",
545
+ ) from error
546
+ if preflight.blockers:
547
+ raise PackImportError(
548
+ "Preflight blocked Import",
549
+ stage="preflight",
550
+ code="PREFLIGHT_BLOCKED",
551
+ details={"findings": [finding.as_dict() for finding in preflight.blockers]},
552
+ )
553
+ try:
554
+ data = pack_data if pack_data is not None else read(source_path)
555
+ except DshPackError as error:
556
+ raise PackImportError(str(error), stage="preflight", code="PACK_READ_ERROR") from error
557
+
558
+ from .portability import check_targets
559
+ check_targets(home_path, data.user_data, overwrite=options.overwrite_existing_profile)
560
+ requested_profile_name = _profile_name(data.manifest)
561
+ profile_name = _select_profile_name(
562
+ home_path,
563
+ requested_profile_name,
564
+ target_name=options.target_profile_name,
565
+ overwrite=options.overwrite_existing_profile,
566
+ )
567
+ profile_path = home_path / "profiles" / profile_name
568
+ metadata_path = home_path / ".dsh-pack" / "imports" / profile_name
569
+ target_exists = profile_path.exists() or metadata_path.exists()
570
+ # _select_profile_name checks both locations. Keep these guards for a
571
+ # clear diagnostic if the filesystem changes between selection and return.
572
+ if target_exists and not options.overwrite_existing_profile:
573
+ raise PackImportError(
574
+ f"no safe target Profile name is available for {requested_profile_name!r}",
575
+ stage="planning",
576
+ code="PROFILE_NAME_UNAVAILABLE",
577
+ details={"requestedProfileName": requested_profile_name, "profilePath": str(profile_path), "metadataPath": str(metadata_path)},
578
+ )
579
+ if "package.json" not in data.profile_files:
580
+ raise PackImportError(
581
+ "Pack profile/ is missing package.json",
582
+ stage="planning",
583
+ code="PROFILE_CONFIG_MISSING",
584
+ )
585
+
586
+ _, host_dependencies = _host_dependency_overrides(data, options)
587
+ return ImportPlan(
588
+ pack_path=source_path,
589
+ dsh_home=home_path,
590
+ requested_profile_name=requested_profile_name,
591
+ profile_name=profile_name,
592
+ profile_path=profile_path,
593
+ metadata_path=metadata_path,
594
+ overwrite=options.overwrite_existing_profile,
595
+ target_exists=target_exists,
596
+ embedded_plugins=_plugin_rows(data.plugins_lock, "embedded"),
597
+ reference_plugins=_plugin_rows(data.plugins_lock, "reference-only"),
598
+ preflight=preflight,
599
+ portable_data=tuple(data.user_data),
600
+ host_provided_dependencies=tuple(host_dependencies),
601
+ )
602
+
603
+
604
+ def _write_file(root: Path, relative: str, content: bytes) -> None:
605
+ archive_path = validate_archive_path(f"profile/{relative}", expected_root="profile")
606
+ relative_path = archive_path[len("profile/"):]
607
+ destination = root.joinpath(*relative_path.split("/"))
608
+ destination.parent.mkdir(parents=True, exist_ok=True)
609
+ if destination.exists() or destination.is_symlink():
610
+ raise PackImportError(
611
+ f"Profile target file already exists: {destination}",
612
+ stage="configuration",
613
+ code="PROFILE_FILE_EXISTS",
614
+ )
615
+ destination.write_bytes(content)
616
+
617
+
618
+ def _extract_embedded_artifact(profile_path: Path, plugin: Mapping[str, Any], content: bytes) -> None:
619
+ name = str(plugin["name"])
620
+ target = _package_install_path(profile_path, name)
621
+ if target.exists():
622
+ raise PackImportError(
623
+ f"embedded plugin target already exists: {target}",
624
+ stage="embedded-install",
625
+ code="PLUGIN_TARGET_EXISTS",
626
+ )
627
+ target.mkdir(parents=True, exist_ok=False)
628
+ seen: set[str] = set()
629
+ try:
630
+ with tarfile.open(fileobj=BytesIO(content), mode="r:gz") as archive:
631
+ for member in archive.getmembers():
632
+ raw_name = member.name.replace("\\", "/")
633
+ if raw_name.endswith("/"):
634
+ raw_name = raw_name[:-1]
635
+ if raw_name == "package":
636
+ continue
637
+ canonical = validate_archive_path(raw_name)
638
+ if not canonical.startswith("package/"):
639
+ raise PackImportError(
640
+ f"artifact member is outside package/: {member.name!r}",
641
+ stage="embedded-install",
642
+ code="ARTIFACT_PATH_INVALID",
643
+ )
644
+ relative = canonical[len("package/"):]
645
+ if not relative or relative in seen:
646
+ raise PackImportError(
647
+ f"duplicate artifact member: {member.name!r}",
648
+ stage="embedded-install",
649
+ code="ARTIFACT_MEMBER_DUPLICATE",
650
+ )
651
+ seen.add(relative)
652
+ destination = target.joinpath(*relative.split("/"))
653
+ if member.issym():
654
+ raise PackImportError(
655
+ f"unsupported artifact symlink member: {member.name!r}",
656
+ stage="embedded-install",
657
+ code="ARTIFACT_MEMBER_TYPE_INVALID",
658
+ )
659
+ if member.isdir():
660
+ destination.mkdir(parents=True, exist_ok=True)
661
+ continue
662
+ if member.islnk():
663
+ # npm tarballs can contain hardlink members. Resolve the
664
+ # target inside the same archive and materialize a regular
665
+ # file so the installed package is a plain directory tree.
666
+ content_bytes = _read_artifact_hardlink(archive, member)
667
+ destination.parent.mkdir(parents=True, exist_ok=True)
668
+ destination.write_bytes(content_bytes)
669
+ continue
670
+ if not member.isfile():
671
+ raise PackImportError(
672
+ f"unsupported artifact member type: {member.name!r}",
673
+ stage="embedded-install",
674
+ code="ARTIFACT_MEMBER_TYPE_INVALID",
675
+ )
676
+ extracted = archive.extractfile(member)
677
+ if extracted is None:
678
+ raise PackImportError(
679
+ f"cannot read artifact member: {member.name!r}",
680
+ stage="embedded-install",
681
+ code="ARTIFACT_MEMBER_READ_ERROR",
682
+ )
683
+ destination.parent.mkdir(parents=True, exist_ok=True)
684
+ destination.write_bytes(extracted.read())
685
+ except PackImportError:
686
+ raise
687
+ except DshPackError as error:
688
+ raise PackImportError(
689
+ f"embedded artifact is unsafe for {name}",
690
+ stage="embedded-install",
691
+ code="ARTIFACT_PATH_INVALID",
692
+ details={"error": str(error)},
693
+ ) from error
694
+ except (OSError, tarfile.TarError) as error:
695
+ raise PackImportError(
696
+ f"cannot unpack embedded artifact for {name}",
697
+ stage="embedded-install",
698
+ code="ARTIFACT_UNPACK_FAILED",
699
+ details={
700
+ "item": name,
701
+ "expected": {"artifact": "valid gzip tar archive", "package": name},
702
+ "observed": {"error": str(error)},
703
+ "evidence": {"package": name, "stage": "embedded-install"},
704
+ "error": str(error),
705
+ },
706
+ ) from error
707
+
708
+
709
+
710
+ def _read_artifact_hardlink(archive: tarfile.TarFile, member: tarfile.TarInfo) -> bytes:
711
+ """Read the file content a tar hardlink member points to.
712
+
713
+ npm preserves hardlinks in packed tarballs. The link target is resolved
714
+ within the same archive only; symlinks, directory targets, missing
715
+ members, and cycles are rejected.
716
+ """
717
+
718
+ resolved: set[str] = set()
719
+ current = member.linkname.replace("\\", "/")
720
+ for _ in range(16):
721
+ canonical = validate_archive_path(current)
722
+ if not canonical.startswith("package/"):
723
+ raise PackImportError(
724
+ f"artifact hardlink target is outside package/: {member.linkname!r}",
725
+ stage="embedded-install",
726
+ code="ARTIFACT_PATH_INVALID",
727
+ )
728
+ if canonical in resolved:
729
+ raise PackImportError(
730
+ f"artifact hardlink target cycle: {member.linkname!r}",
731
+ stage="embedded-install",
732
+ code="ARTIFACT_MEMBER_TYPE_INVALID",
733
+ )
734
+ resolved.add(canonical)
735
+ try:
736
+ target_member = archive.getmember(canonical)
737
+ except KeyError:
738
+ raise PackImportError(
739
+ f"artifact hardlink target is missing: {canonical!r}",
740
+ stage="embedded-install",
741
+ code="ARTIFACT_MEMBER_READ_ERROR",
742
+ ) from None
743
+ if target_member.issym() or target_member.isdir():
744
+ raise PackImportError(
745
+ f"artifact hardlink target is not a regular file: {canonical!r}",
746
+ stage="embedded-install",
747
+ code="ARTIFACT_MEMBER_TYPE_INVALID",
748
+ )
749
+ if target_member.islnk():
750
+ current = target_member.linkname.replace("\\", "/")
751
+ continue
752
+ extracted = archive.extractfile(target_member)
753
+ if extracted is None:
754
+ raise PackImportError(
755
+ f"cannot read artifact hardlink target: {canonical!r}",
756
+ stage="embedded-install",
757
+ code="ARTIFACT_MEMBER_READ_ERROR",
758
+ )
759
+ return extracted.read()
760
+ raise PackImportError(
761
+ f"artifact hardlink chain is too long: {member.name!r}",
762
+ stage="embedded-install",
763
+ code="ARTIFACT_MEMBER_TYPE_INVALID",
764
+ )
765
+
766
+
767
+ def _copy_reference_source(profile_path: Path, plugin: Mapping[str, Any], source: Path) -> None:
768
+ name = str(plugin["name"])
769
+ target = _package_install_path(profile_path, name)
770
+ if not source.is_dir():
771
+ raise PackImportError(
772
+ f"reference plugin source is not a directory: {source}",
773
+ stage="reference-install",
774
+ code="PLUGIN_SOURCE_MISSING",
775
+ )
776
+ source = source.resolve()
777
+ if target.exists():
778
+ raise PackImportError(
779
+ f"reference plugin target already exists: {target}",
780
+ stage="reference-install",
781
+ code="PLUGIN_TARGET_EXISTS",
782
+ )
783
+ target.mkdir(parents=True, exist_ok=False)
784
+
785
+ def is_directory_link(path: Path) -> bool:
786
+ junction_check = getattr(path, "is_junction", None)
787
+ return path.is_symlink() or (callable(junction_check) and junction_check())
788
+
789
+ for current_root, directory_names, file_names in os.walk(source, topdown=True, followlinks=False):
790
+ current_path = Path(current_root)
791
+ retained_directories: list[str] = []
792
+ for directory_name in directory_names:
793
+ directory_path = current_path / directory_name
794
+ relative_parts = directory_path.relative_to(source).parts
795
+ if any(part in _REFERENCE_SOURCE_SKIP_DIRS for part in relative_parts):
796
+ continue
797
+ if is_directory_link(directory_path):
798
+ raise PackImportError(
799
+ f"reference plugin source contains symlink or junction: {directory_path}",
800
+ stage="reference-install",
801
+ code="PLUGIN_SOURCE_UNSAFE",
802
+ )
803
+ retained_directories.append(directory_name)
804
+ directory_names[:] = retained_directories
805
+
806
+ for file_name in file_names:
807
+ path = current_path / file_name
808
+ relative = path.relative_to(source).as_posix()
809
+ validate_archive_path(f"package/{relative}", expected_root="package")
810
+ if path.is_symlink():
811
+ raise PackImportError(
812
+ f"reference plugin source contains symlink: {path}",
813
+ stage="reference-install",
814
+ code="PLUGIN_SOURCE_UNSAFE",
815
+ )
816
+ destination = target / Path(*path.relative_to(source).parts)
817
+ destination.parent.mkdir(parents=True, exist_ok=True)
818
+ shutil.copy2(path, destination)
819
+
820
+
821
+ def _verify_identity(profile_path: Path, plugin: Mapping[str, Any]) -> dict[str, Any]:
822
+ name = str(plugin["name"])
823
+ expected_version = expected_install_version(plugin)
824
+ installed = _package_manifest(_package_install_path(profile_path, name), stage="version-confirmation")
825
+ if installed["name"] != name or installed["version"] != expected_version:
826
+ raise PackImportError(
827
+ f"installed {name}@{installed.get('version')} does not match Pack {name}@{expected_version}",
828
+ stage="version-confirmation",
829
+ code="PLUGIN_VERSION_MISMATCH",
830
+ details={
831
+ "plugin": name,
832
+ "expected": {"name": name, "version": expected_version},
833
+ "observed": {"name": installed.get("name"), "version": installed.get("version")},
834
+ },
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
873
+ return {"name": name, "version": installed["version"], "mode": plugin["artifact"]["mode"]}
874
+
875
+
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]:
1152
+ return {
1153
+ "schemaVersion": 1,
1154
+ "status": "prepared",
1155
+ "pack": {"sha256": pack_digest, "path": str(plan.pack_path)},
1156
+ "profile": {"name": plan.profile_name, "path": str(plan.profile_path)},
1157
+ "overwrite": plan.overwrite,
1158
+ "plugins": list(installed),
1159
+ "requiredSecrets": list(data.manifest.get("requiredSecrets", [])),
1160
+ **({'installation': {key: value for key, value in installation.items() if key != 'stdout'}} if installation else {}),
1161
+ }
1162
+
1163
+
1164
+ def _cleanup(path: Path | None) -> None:
1165
+ if path is None or not path.exists():
1166
+ return
1167
+ if path.is_dir():
1168
+ shutil.rmtree(path)
1169
+ else:
1170
+ path.unlink()
1171
+
1172
+
1173
+ def _cleanup_empty(path: Path | None) -> None:
1174
+ """Remove a newly-created directory only when it is empty."""
1175
+
1176
+ if path is None or not path.is_dir():
1177
+ return
1178
+ try:
1179
+ next(path.iterdir())
1180
+ except StopIteration:
1181
+ path.rmdir()
1182
+
1183
+
1184
+ def import_pack(
1185
+ source: str | os.PathLike[str],
1186
+ dsh_home: str | os.PathLike[str],
1187
+ *,
1188
+ options: ImportOptions | None = None,
1189
+ ) -> ImportResult:
1190
+ """Import one Pack as a prepared Profile with an explicit safe mode.
1191
+
1192
+ The default mode creates a new Profile and refuses duplicate names. An
1193
+ overwrite is accepted only when the caller supplies the exact confirmation
1194
+ token produced after a user-facing confirmation dialog.
1195
+ """
1196
+
1197
+ options = options or ImportOptions()
1198
+ if options.overwrite_existing_profile and options.overwrite_confirmation != "OVERWRITE":
1199
+ raise PackImportError(
1200
+ "overwriting an existing Profile requires explicit confirmation",
1201
+ stage="planning",
1202
+ code="OVERWRITE_CONFIRMATION_REQUIRED",
1203
+ details={
1204
+ "expected": "OVERWRITE",
1205
+ "observed": "confirmation missing or invalid",
1206
+ "impact": "No Profile was changed.",
1207
+ "suggestedNextStep": "Confirm the destructive overwrite in the user interface and retry.",
1208
+ },
1209
+ )
1210
+
1211
+
1212
+ if options.overwrite_confirmation is not None and not options.overwrite_existing_profile:
1213
+ raise PackImportError(
1214
+ "overwrite confirmation cannot be used without overwrite mode",
1215
+ stage="planning",
1216
+ code="OVERWRITE_MODE_REQUIRED",
1217
+ details={"impact": "No Profile was changed."},
1218
+ )
1219
+ source_path = Path(source).resolve()
1220
+ home_path = Path(dsh_home).resolve()
1221
+ try:
1222
+ pack_data = read(source_path)
1223
+ except DshPackError:
1224
+ pack_data = None
1225
+ if pack_data is not None:
1226
+ options = _auto_reference_sources(pack_data, home_path, options)
1227
+ plan = create_import_plan(source_path, home_path, options=options)
1228
+ try:
1229
+ data = read(plan.pack_path)
1230
+ except DshPackError as error:
1231
+ raise PackImportError(str(error), stage="preflight", code="PACK_READ_ERROR") from error
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] = []
1237
+ profiles_root = plan.dsh_home / "profiles"
1238
+ temp_profile: Path | None = None
1239
+ final_created = False
1240
+ metadata_created = False
1241
+ original_profile_backup: Path | None = None
1242
+ original_metadata_backup: Path | None = None
1243
+ backup_token = uuid.uuid4().hex
1244
+ network_plugins = {str(plugin.get("name")): plugin for plugin in _network_reference_plugins(data, options)}
1245
+ network_installs: list[dict[str, Any]] = []
1246
+ warnings: list[dict[str, Any]] = []
1247
+ metadata_parent = plan.metadata_path.parent
1248
+ metadata_root = metadata_parent.parent
1249
+ metadata_parent_existed = metadata_parent.exists()
1250
+ metadata_root_existed = metadata_root.exists()
1251
+ try:
1252
+ profiles_root.mkdir(parents=True, exist_ok=True)
1253
+ temp_profile = Path(tempfile.mkdtemp(prefix=f".{plan.profile_name}.dsh-pack-", dir=profiles_root))
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
1260
+ _write_file(temp_profile, relative, content)
1261
+
1262
+ installed: list[dict[str, Any]] = []
1263
+ lock_plugins = [plugin for plugin in data.plugins_lock.get("plugins", []) if isinstance(plugin, Mapping)]
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)
1272
+ for plugin in lock_plugins:
1273
+ artifact = plugin.get("artifact")
1274
+ if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only":
1275
+ continue
1276
+ name = str(plugin["name"])
1277
+ if options.reference_installer is not None or name in options.reference_plugin_sources:
1278
+ continue
1279
+ if not (options.allow_network_reference_install and name in network_plugins):
1280
+ continue
1281
+ try:
1282
+ network_installs.append(_install_network_reference(temp_profile, plugin, options))
1283
+ except PackImportError as error:
1284
+ if plugin.get("required") is True:
1285
+ raise
1286
+ _remove_staged_package(temp_profile, name, stage="network-install")
1287
+ warnings.append(error.as_dict())
1288
+ continue
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
+
1295
+ for plugin in lock_plugins:
1296
+ artifact = plugin.get("artifact")
1297
+ if not isinstance(artifact, Mapping) or artifact.get("mode") != "embedded":
1298
+ continue
1299
+ path = artifact.get("path")
1300
+ if not isinstance(path, str):
1301
+ raise PackImportError(
1302
+ f"embedded artifact path missing for {plugin.get('name')}",
1303
+ stage="embedded-install",
1304
+ code="ARTIFACT_PATH_MISSING",
1305
+ )
1306
+ content = data.plugin_artifacts.get(path[len("plugins/"):])
1307
+ if content is None:
1308
+ raise PackImportError(
1309
+ f"embedded artifact missing for {plugin.get('name')}: {path}",
1310
+ stage="embedded-install",
1311
+ code="REQUIRED_ARTIFACT_MISSING",
1312
+ )
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"]))
1316
+
1317
+ for plugin in lock_plugins:
1318
+ artifact = plugin.get("artifact")
1319
+ if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only":
1320
+ continue
1321
+ name = str(plugin["name"])
1322
+ if options.reference_installer is not None:
1323
+ try:
1324
+ options.reference_installer(temp_profile, plugin)
1325
+ except PackImportError:
1326
+ raise
1327
+ except Exception as error:
1328
+ raise PackImportError(
1329
+ f"reference-only plugin installation failed: {name}",
1330
+ stage="reference-install",
1331
+ code="PLUGIN_INSTALL_FAILED",
1332
+ details={"plugin": name, "error": str(error)},
1333
+ ) from error
1334
+ installed_names.add(name)
1335
+ continue
1336
+ if name in options.reference_plugin_sources:
1337
+ _remove_staged_package(temp_profile, name, stage="reference-install")
1338
+ _copy_reference_source(temp_profile, plugin, Path(options.reference_plugin_sources[name]))
1339
+ installed_names.add(name)
1340
+ continue
1341
+ if options.allow_network_reference_install and name in network_plugins:
1342
+ continue # final identity verification follows all installs
1343
+ if plugin.get("required") is True:
1344
+ raise PackImportError(
1345
+ f"required reference-only plugin was not installed: {name}",
1346
+ stage="reference-install",
1347
+ code="PLUGIN_MISSING",
1348
+ details={"plugin": name},
1349
+ )
1350
+
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)
1364
+ pack_digest = sha256_bytes(plan.pack_path.read_bytes())
1365
+ final_profile = plan.profile_path
1366
+ if final_profile.exists():
1367
+ if not plan.overwrite:
1368
+ raise PackImportError(
1369
+ f"target Profile appeared during Import: {final_profile}",
1370
+ stage="commit",
1371
+ code="PROFILE_EXISTS",
1372
+ )
1373
+ original_profile_backup = profiles_root / f".{plan.profile_name}.dsh-pack-backup-{backup_token}"
1374
+ final_profile.rename(original_profile_backup)
1375
+ if plan.metadata_path.exists():
1376
+ if not plan.overwrite:
1377
+ raise PackImportError(
1378
+ f"target Import metadata appeared during Import: {plan.metadata_path}",
1379
+ stage="commit",
1380
+ code="PROFILE_EXISTS",
1381
+ )
1382
+ original_metadata_backup = metadata_parent / f".{plan.profile_name}.dsh-pack-backup-{backup_token}"
1383
+ plan.metadata_path.rename(original_metadata_backup)
1384
+ temp_profile.rename(final_profile)
1385
+ temp_profile = None
1386
+ final_created = True
1387
+
1388
+ metadata_parent.mkdir(parents=True, exist_ok=True)
1389
+ plan.metadata_path.mkdir(parents=False, exist_ok=False)
1390
+ metadata_created = True
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")
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")
1393
+ (plan.metadata_path / "prepared.json").write_bytes(
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"
1396
+ )
1397
+ for backup in (original_profile_backup, original_metadata_backup):
1398
+ if backup is None:
1399
+ continue
1400
+ try:
1401
+ _cleanup(backup)
1402
+ except OSError as error:
1403
+ warnings.append(
1404
+ {
1405
+ "code": "OVERWRITE_BACKUP_RETAINED",
1406
+ "stage": "commit",
1407
+ "item": str(backup),
1408
+ "message": "previous Profile data was retained as a recovery backup",
1409
+ "details": {"path": str(backup), "error": str(error)},
1410
+ }
1411
+ )
1412
+ return ImportResult(
1413
+ status="prepared",
1414
+ profile_path=final_profile,
1415
+ metadata_path=plan.metadata_path,
1416
+ plan=plan,
1417
+ installed_plugins=tuple(installed),
1418
+ network_installs=tuple(network_installs),
1419
+ warnings=tuple(warnings),
1420
+ )
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(
1430
+ "Import failed unexpectedly",
1431
+ stage="commit",
1432
+ code="IMPORT_FAILED",
1433
+ details={
1434
+ "item": "prepared metadata / Profile commit",
1435
+ "expected": {"operation": "write prepared metadata and commit the new Profile"},
1436
+ "observed": observed,
1437
+ "evidence": {"profilePath": str(plan.profile_path), "metadataPath": str(plan.metadata_path)},
1438
+ "impact": "Import failed; inspect the recorded rollback outcome",
1439
+ "error": str(error),
1440
+ },
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
1504
+
1505
+
1506
+ def delete_profile(
1507
+ dsh_home: str | os.PathLike[str],
1508
+ profile_name: str,
1509
+ *,
1510
+ confirmation: str | None = None,
1511
+ ) -> DeleteResult:
1512
+ """Delete exactly one Profile and its Import metadata after explicit confirmation.
1513
+
1514
+ Deleting a Profile also removes ``DSH_HOME/.dsh-pack/imports/<name>`` so
1515
+ the name is not left permanently reserved by a stale prepared record. If
1516
+ metadata cleanup fails, the Profile itself stays deleted and the failure is
1517
+ reported through ``DeleteResult.metadata_status``.
1518
+ """
1519
+
1520
+ if confirmation != "DELETE":
1521
+ raise PackImportError(
1522
+ "deleting a Profile requires explicit confirmation",
1523
+ stage="planning",
1524
+ code="DELETE_CONFIRMATION_REQUIRED",
1525
+ details={
1526
+ "expected": "DELETE",
1527
+ "observed": "confirmation missing or invalid",
1528
+ "impact": "No Profile was changed.",
1529
+ "suggestedNextStep": "Confirm the destructive deletion in the user interface and retry.",
1530
+ },
1531
+ )
1532
+ safe_name = _validate_profile_name(profile_name, label="Target Profile name")
1533
+ home = Path(dsh_home).resolve()
1534
+ profiles_root = home / "profiles"
1535
+ target = profiles_root / safe_name
1536
+ if target.is_symlink() or (target.exists() and not target.is_dir()):
1537
+ raise PackImportError(
1538
+ f"target Profile is not a safe directory: {target}",
1539
+ stage="planning",
1540
+ code="PROFILE_TARGET_INVALID",
1541
+ details={"profilePath": str(target)},
1542
+ )
1543
+ if not target.exists():
1544
+ raise PackImportError(
1545
+ f"target Profile does not exist: {target}",
1546
+ stage="planning",
1547
+ code="PROFILE_NOT_FOUND",
1548
+ details={"profilePath": str(target)},
1549
+ )
1550
+ metadata_target = home / ".dsh-pack" / "imports" / safe_name
1551
+ if metadata_target.is_symlink() or (metadata_target.exists() and not metadata_target.is_dir()):
1552
+ raise PackImportError(
1553
+ f"Import metadata for the target Profile is not a safe directory: {metadata_target}",
1554
+ stage="planning",
1555
+ code="PROFILE_TARGET_INVALID",
1556
+ details={"metadataPath": str(metadata_target)},
1557
+ )
1558
+ backup = profiles_root / f".{safe_name}.dsh-pack-delete-{uuid.uuid4().hex}"
1559
+ try:
1560
+ target.rename(backup)
1561
+ shutil.rmtree(backup)
1562
+ except OSError as error:
1563
+ if backup.exists() and not target.exists():
1564
+ try:
1565
+ backup.rename(target)
1566
+ except OSError:
1567
+ pass
1568
+ raise PackImportError(
1569
+ f"failed to delete Profile: {target}",
1570
+ stage="delete",
1571
+ code="PROFILE_DELETE_FAILED",
1572
+ details={
1573
+ "profilePath": str(target),
1574
+ "error": str(error),
1575
+ "originalProfileStatus": "restored" if target.exists() else "unknown",
1576
+ },
1577
+ ) from error
1578
+ metadata_status = "no-metadata"
1579
+ if metadata_target.exists():
1580
+ metadata_backup = metadata_target.parent / f".{safe_name}.dsh-pack-delete-{uuid.uuid4().hex}"
1581
+ try:
1582
+ metadata_target.rename(metadata_backup)
1583
+ shutil.rmtree(metadata_backup)
1584
+ metadata_status = "removed"
1585
+ except OSError as error:
1586
+ metadata_status = "failed"
1587
+ if metadata_backup.exists() and not metadata_target.exists():
1588
+ try:
1589
+ metadata_backup.rename(metadata_target)
1590
+ except OSError:
1591
+ pass
1592
+ return DeleteResult(
1593
+ status="deleted",
1594
+ profile_name=safe_name,
1595
+ profile_path=target,
1596
+ metadata_path=metadata_target,
1597
+ metadata_status=metadata_status,
1598
+ )