dsh-crate-web 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/core/dsh_pack/importer.py +1347 -1347
- package/core/dsh_pack/verify.py +1336 -1336
- package/package.json +3 -2
- package/restart-helper.mjs +121 -121
- package/runtime/python/LICENSE.txt +676 -676
- package/runtime/python/__install__.json +81 -81
- package/runtime/python/python314._pth +6 -6
|
@@ -1,1347 +1,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 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 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
|
+
|