dsh-crate-web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +302 -0
- package/README.zh-CN.md +304 -0
- package/cordis.patch.yml +11 -0
- package/core/dsh_pack/__init__.py +77 -0
- package/core/dsh_pack/__main__.py +4 -0
- package/core/dsh_pack/cli.py +372 -0
- package/core/dsh_pack/errors.py +167 -0
- package/core/dsh_pack/export.py +912 -0
- package/core/dsh_pack/importer.py +1347 -0
- package/core/dsh_pack/network.py +77 -0
- package/core/dsh_pack/pack.py +248 -0
- package/core/dsh_pack/preflight.py +811 -0
- package/core/dsh_pack/user_cli.py +462 -0
- package/core/dsh_pack/validation.py +272 -0
- package/core/dsh_pack/verify.py +1336 -0
- package/lib/client.js +1623 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.js +1275 -0
- package/package.json +73 -0
- package/restart-helper.mjs +121 -0
- package/runtime/python/LICENSE.txt +676 -0
- package/runtime/python/__install__.json +82 -0
- package/runtime/python/_asyncio.pyd +0 -0
- package/runtime/python/_bz2.pyd +0 -0
- package/runtime/python/_ctypes.pyd +0 -0
- package/runtime/python/_decimal.pyd +0 -0
- package/runtime/python/_elementtree.pyd +0 -0
- package/runtime/python/_hashlib.pyd +0 -0
- package/runtime/python/_lzma.pyd +0 -0
- package/runtime/python/_multiprocessing.pyd +0 -0
- package/runtime/python/_overlapped.pyd +0 -0
- package/runtime/python/_queue.pyd +0 -0
- package/runtime/python/_remote_debugging.pyd +0 -0
- package/runtime/python/_socket.pyd +0 -0
- package/runtime/python/_sqlite3.pyd +0 -0
- package/runtime/python/_ssl.pyd +0 -0
- package/runtime/python/_uuid.pyd +0 -0
- package/runtime/python/_wmi.pyd +0 -0
- package/runtime/python/_zoneinfo.pyd +0 -0
- package/runtime/python/_zstd.pyd +0 -0
- package/runtime/python/libcrypto-3.dll +0 -0
- package/runtime/python/libffi-8.dll +0 -0
- package/runtime/python/libssl-3.dll +0 -0
- package/runtime/python/pyexpat.pyd +0 -0
- package/runtime/python/python.cat +0 -0
- package/runtime/python/python.exe +0 -0
- package/runtime/python/python3.dll +0 -0
- package/runtime/python/python314._pth +6 -0
- package/runtime/python/python314.dll +0 -0
- package/runtime/python/python314.zip +0 -0
- package/runtime/python/pythonw.exe +0 -0
- package/runtime/python/select.pyd +0 -0
- package/runtime/python/sqlite3.dll +0 -0
- package/runtime/python/unicodedata.pyd +0 -0
- package/runtime/python/vcruntime140.dll +0 -0
- package/runtime/python/vcruntime140_1.dll +0 -0
- package/runtime/python/winsound.pyd +0 -0
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
"""Phase 2 Profile-to-Pack export.
|
|
2
|
+
|
|
3
|
+
This module reads an existing Profile directory. It never installs a Profile,
|
|
4
|
+
starts DSH, invokes pnpm, or contacts a registry. Embedded artifacts are made
|
|
5
|
+
with ``npm pack`` against an already installed local package directory only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import copy
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import posixpath
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import tarfile
|
|
20
|
+
import tempfile
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Mapping, Sequence
|
|
24
|
+
|
|
25
|
+
from .errors import DshPackError, ExportError
|
|
26
|
+
from .pack import PackData, create, sha256_bytes
|
|
27
|
+
from .validation import validate_payload_key
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
REFERENCE_ONLY = "reference-only"
|
|
31
|
+
EMBEDDED = "embedded"
|
|
32
|
+
_VALID_MODES = {REFERENCE_ONLY, EMBEDDED}
|
|
33
|
+
_SOURCE_TYPE_PREFIXES = {
|
|
34
|
+
"git": ("git:", "github:", "git+", ".git"),
|
|
35
|
+
"file": ("file:", "./", "../"),
|
|
36
|
+
"link": ("link:",),
|
|
37
|
+
"tarball": ("http://", "https://"),
|
|
38
|
+
"workspace": ("workspace:",),
|
|
39
|
+
}
|
|
40
|
+
_SECRET_NAME_RE = re.compile(
|
|
41
|
+
r"(?i)(?:api[_-]?key|access[_-]?token|auth[_-]?token|password|secret|private[_-]?key)"
|
|
42
|
+
)
|
|
43
|
+
_SECRET_ASSIGNMENT_RE = re.compile(
|
|
44
|
+
r"(?im)\b(?:[A-Z][A-Z0-9_]*(?:API[_-]?KEY|TOKEN|PASSWORD|SECRET|PRIVATE[_-]?KEY))\b\s*[:=]\s*['\"]?[^\s,'\"}]+"
|
|
45
|
+
)
|
|
46
|
+
_SEMVER_RE = re.compile(
|
|
47
|
+
r"^[v=]?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
|
48
|
+
r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
|
|
49
|
+
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
|
50
|
+
)
|
|
51
|
+
_SKIP_DIRS = {"node_modules", ".git", "cache", "logs", ".dshpack-work"}
|
|
52
|
+
_SKIP_FILES = {
|
|
53
|
+
".credentials.yaml",
|
|
54
|
+
".credentials.yml",
|
|
55
|
+
"credentials.json",
|
|
56
|
+
"credentials.yaml",
|
|
57
|
+
"credentials.yml",
|
|
58
|
+
".env",
|
|
59
|
+
".env.local",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class PluginExportOptions:
|
|
65
|
+
"""User-selected artifact mode and requiredness for one plugin."""
|
|
66
|
+
|
|
67
|
+
mode: str = REFERENCE_ONLY
|
|
68
|
+
required: bool = True
|
|
69
|
+
|
|
70
|
+
def __post_init__(self) -> None:
|
|
71
|
+
if self.mode not in _VALID_MODES:
|
|
72
|
+
raise ExportError(f"unsupported artifact mode: {self.mode!r}")
|
|
73
|
+
if not isinstance(self.required, bool):
|
|
74
|
+
raise ExportError("PluginExportOptions.required must be a boolean")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class ExportOptions:
|
|
79
|
+
"""Environment and per-plugin choices required for one export."""
|
|
80
|
+
|
|
81
|
+
environment: Mapping[str, Any]
|
|
82
|
+
required_secrets: tuple[str, ...] = ()
|
|
83
|
+
plugin_options: Mapping[str, PluginExportOptions] = field(default_factory=dict)
|
|
84
|
+
npm_command: str | None = None
|
|
85
|
+
include_installation_bundles: bool = False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class ExportResult:
|
|
90
|
+
"""The created Pack path and semantic data used to create it."""
|
|
91
|
+
|
|
92
|
+
path: Path
|
|
93
|
+
data: PackData
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _imported_required_secrets(profile_dir: Path) -> tuple[str, ...]:
|
|
98
|
+
"""Return requiredSecrets recorded for this Profile by a prior Import.
|
|
99
|
+
|
|
100
|
+
Import keeps Pack metadata outside the runtime Profile under
|
|
101
|
+
``DSH_HOME/.dsh-pack/imports/<profile>/manifest.json``. Re-export of an
|
|
102
|
+
imported Profile must preserve the required Secret names so the Pack keeps
|
|
103
|
+
its original semantics; values are never stored or returned.
|
|
104
|
+
"""
|
|
105
|
+
metadata = profile_dir.parent.parent / ".dsh-pack" / "imports" / profile_dir.name / "manifest.json"
|
|
106
|
+
try:
|
|
107
|
+
value = json.loads(metadata.read_text(encoding="utf-8"))
|
|
108
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
109
|
+
return ()
|
|
110
|
+
if not isinstance(value, dict):
|
|
111
|
+
return ()
|
|
112
|
+
secrets = value.get("requiredSecrets", [])
|
|
113
|
+
if not isinstance(secrets, list):
|
|
114
|
+
return ()
|
|
115
|
+
return tuple(name for name in secrets if isinstance(name, str) and name.strip())
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _read_json_file(path: Path, label: str) -> dict[str, Any]:
|
|
119
|
+
try:
|
|
120
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
121
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
122
|
+
raise ExportError(f"cannot read {label}: {path}") from error
|
|
123
|
+
if not isinstance(value, dict):
|
|
124
|
+
raise ExportError(f"{label} must contain a JSON object: {path}")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _string(value: Any, path: str) -> str:
|
|
129
|
+
if not isinstance(value, str) or not value.strip():
|
|
130
|
+
raise ExportError(f"{path} must be a non-empty string")
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _source_type(specifier: str) -> str:
|
|
135
|
+
for source_type, prefixes in _SOURCE_TYPE_PREFIXES.items():
|
|
136
|
+
if specifier.startswith(prefixes):
|
|
137
|
+
return source_type
|
|
138
|
+
if specifier.startswith("npm:"):
|
|
139
|
+
return "alias"
|
|
140
|
+
return "registry"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _repository_specifier(package_manifest: Mapping[str, Any]) -> str | None:
|
|
144
|
+
"""Return a portable source locator from npm ``repository`` metadata.
|
|
145
|
+
|
|
146
|
+
DSH installation-anchor Bundles are not always listed in the Profile's
|
|
147
|
+
dependency map. In that case the installed package manifest is the last
|
|
148
|
+
authoritative local source record we have. Keep the locator in the Pack
|
|
149
|
+
instead of silently falling back to a package name with ``unknown``.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
repository = package_manifest.get("repository")
|
|
153
|
+
if isinstance(repository, str):
|
|
154
|
+
candidate = repository.strip()
|
|
155
|
+
elif isinstance(repository, Mapping):
|
|
156
|
+
candidate = repository.get("url")
|
|
157
|
+
if not isinstance(candidate, str):
|
|
158
|
+
return None
|
|
159
|
+
candidate = candidate.strip()
|
|
160
|
+
else:
|
|
161
|
+
return None
|
|
162
|
+
if not candidate:
|
|
163
|
+
return None
|
|
164
|
+
if candidate.startswith(("git:", "git+", "github:", "ssh://", ".git")):
|
|
165
|
+
return candidate
|
|
166
|
+
if candidate.startswith(("http://", "https://")):
|
|
167
|
+
return f"git+{candidate}"
|
|
168
|
+
return candidate
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _effective_specifier(
|
|
172
|
+
package_name: str,
|
|
173
|
+
specifier: str,
|
|
174
|
+
package_manifest: Mapping[str, Any],
|
|
175
|
+
) -> str:
|
|
176
|
+
if specifier != "unknown":
|
|
177
|
+
return specifier
|
|
178
|
+
inferred = _repository_specifier(package_manifest)
|
|
179
|
+
if inferred:
|
|
180
|
+
return inferred
|
|
181
|
+
raise ExportError(
|
|
182
|
+
f"cannot export {package_name}: package source is unavailable; "
|
|
183
|
+
"Profile dependency specifier or package.json.repository is required"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class _SemVer:
|
|
189
|
+
major: int
|
|
190
|
+
minor: int
|
|
191
|
+
patch: int
|
|
192
|
+
prerelease: tuple[str | int, ...] = ()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _parse_semver(value: str) -> _SemVer | None:
|
|
196
|
+
match = _SEMVER_RE.fullmatch(value.strip())
|
|
197
|
+
if not match:
|
|
198
|
+
return None
|
|
199
|
+
prerelease = tuple(
|
|
200
|
+
int(identifier) if identifier.isdigit() else identifier
|
|
201
|
+
for identifier in (match.group(4) or "").split(".")
|
|
202
|
+
if identifier
|
|
203
|
+
)
|
|
204
|
+
return _SemVer(int(match.group(1)), int(match.group(2)), int(match.group(3)), prerelease)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _compare_semver(left: _SemVer, right: _SemVer) -> int:
|
|
208
|
+
left_core = (left.major, left.minor, left.patch)
|
|
209
|
+
right_core = (right.major, right.minor, right.patch)
|
|
210
|
+
if left_core != right_core:
|
|
211
|
+
return (left_core > right_core) - (left_core < right_core)
|
|
212
|
+
if not left.prerelease and not right.prerelease:
|
|
213
|
+
return 0
|
|
214
|
+
if not left.prerelease:
|
|
215
|
+
return 1
|
|
216
|
+
if not right.prerelease:
|
|
217
|
+
return -1
|
|
218
|
+
for left_id, right_id in zip(left.prerelease, right.prerelease):
|
|
219
|
+
if left_id == right_id:
|
|
220
|
+
continue
|
|
221
|
+
if isinstance(left_id, int) and isinstance(right_id, str):
|
|
222
|
+
return -1
|
|
223
|
+
if isinstance(left_id, str) and isinstance(right_id, int):
|
|
224
|
+
return 1
|
|
225
|
+
return (left_id > right_id) - (left_id < right_id)
|
|
226
|
+
return (len(left.prerelease) > len(right.prerelease)) - (
|
|
227
|
+
len(left.prerelease) < len(right.prerelease)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _parse_range_version(value: str) -> tuple[_SemVer, int | None] | None:
|
|
232
|
+
value = value.strip()
|
|
233
|
+
full = _parse_semver(value)
|
|
234
|
+
if full is not None:
|
|
235
|
+
return full, None
|
|
236
|
+
if value.startswith("v"):
|
|
237
|
+
value = value[1:]
|
|
238
|
+
parts = value.split(".")
|
|
239
|
+
if not 1 <= len(parts) <= 3:
|
|
240
|
+
return None
|
|
241
|
+
numbers: list[int] = []
|
|
242
|
+
wildcard_index: int | None = None
|
|
243
|
+
for index, part in enumerate(parts):
|
|
244
|
+
if part.lower() in {"x", "*"}:
|
|
245
|
+
wildcard_index = index
|
|
246
|
+
break
|
|
247
|
+
if not part.isdigit():
|
|
248
|
+
return None
|
|
249
|
+
numbers.append(int(part))
|
|
250
|
+
if wildcard_index is None:
|
|
251
|
+
wildcard_index = len(parts) if len(parts) < 3 else None
|
|
252
|
+
if wildcard_index is not None and any(part.lower() not in {"x", "*"} for part in parts[wildcard_index:]):
|
|
253
|
+
return None
|
|
254
|
+
while len(numbers) < 3:
|
|
255
|
+
numbers.append(0)
|
|
256
|
+
return _SemVer(*numbers), wildcard_index
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _range_clause_matches(candidate: _SemVer, clause: str) -> bool | None:
|
|
260
|
+
clause = clause.strip()
|
|
261
|
+
if not clause or clause in {"*", "x", "X"}:
|
|
262
|
+
return True
|
|
263
|
+
if clause.startswith("^") or clause.startswith("~"):
|
|
264
|
+
operator = clause[0]
|
|
265
|
+
parsed = _parse_range_version(clause[1:])
|
|
266
|
+
if parsed is None:
|
|
267
|
+
return None
|
|
268
|
+
lower, wildcard_index = parsed
|
|
269
|
+
if operator == "^":
|
|
270
|
+
if lower.major > 0:
|
|
271
|
+
upper = _SemVer(lower.major + 1, 0, 0)
|
|
272
|
+
elif lower.minor > 0:
|
|
273
|
+
upper = _SemVer(0, lower.minor + 1, 0)
|
|
274
|
+
else:
|
|
275
|
+
upper = _SemVer(0, 0, lower.patch + 1)
|
|
276
|
+
elif wildcard_index == 0:
|
|
277
|
+
upper = _SemVer(lower.major + 1, 0, 0)
|
|
278
|
+
else:
|
|
279
|
+
upper = _SemVer(lower.major, lower.minor + 1, 0)
|
|
280
|
+
return _compare_semver(candidate, lower) >= 0 and _compare_semver(candidate, upper) < 0
|
|
281
|
+
|
|
282
|
+
match = re.fullmatch(r"(<=|>=|<|>|=)?(.+)", clause)
|
|
283
|
+
if not match:
|
|
284
|
+
return None
|
|
285
|
+
operator = match.group(1) or ""
|
|
286
|
+
parsed = _parse_range_version(match.group(2))
|
|
287
|
+
if parsed is None:
|
|
288
|
+
return None
|
|
289
|
+
bound, wildcard_index = parsed
|
|
290
|
+
comparison = _compare_semver(candidate, bound)
|
|
291
|
+
if wildcard_index is None:
|
|
292
|
+
return {
|
|
293
|
+
"": comparison == 0,
|
|
294
|
+
"=": comparison == 0,
|
|
295
|
+
">": comparison > 0,
|
|
296
|
+
">=": comparison >= 0,
|
|
297
|
+
"<": comparison < 0,
|
|
298
|
+
"<=": comparison <= 0,
|
|
299
|
+
}[operator]
|
|
300
|
+
if operator in {"", "="}:
|
|
301
|
+
if wildcard_index == 0:
|
|
302
|
+
return True
|
|
303
|
+
if wildcard_index == 1:
|
|
304
|
+
return candidate.major == bound.major
|
|
305
|
+
return candidate.major == bound.major and candidate.minor == bound.minor
|
|
306
|
+
upper = (
|
|
307
|
+
_SemVer(bound.major + 1, 0, 0)
|
|
308
|
+
if wildcard_index == 0
|
|
309
|
+
else _SemVer(bound.major, bound.minor + 1, 0)
|
|
310
|
+
)
|
|
311
|
+
if operator == ">=":
|
|
312
|
+
return comparison >= 0
|
|
313
|
+
if operator == ">":
|
|
314
|
+
return _compare_semver(candidate, upper) >= 0
|
|
315
|
+
if operator == "<":
|
|
316
|
+
return comparison < 0
|
|
317
|
+
return _compare_semver(candidate, upper) < 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _registry_spec_satisfies(specifier: str, version: str) -> bool | None:
|
|
321
|
+
"""Check common npm semver ranges without resolving a registry or lockfile."""
|
|
322
|
+
|
|
323
|
+
if not re.search(r"(?:^|[\s|])(?:[v=]?\d|[~^<>=*xX])", specifier):
|
|
324
|
+
return None
|
|
325
|
+
candidate = _parse_semver(version)
|
|
326
|
+
if candidate is None:
|
|
327
|
+
return False
|
|
328
|
+
outcomes: list[bool | None] = []
|
|
329
|
+
for alternative in specifier.split("||"):
|
|
330
|
+
clauses = alternative.strip().split()
|
|
331
|
+
clause_results = [_range_clause_matches(candidate, clause) for clause in clauses]
|
|
332
|
+
if all(result is True for result in clause_results):
|
|
333
|
+
outcomes.append(True)
|
|
334
|
+
elif any(result is None for result in clause_results):
|
|
335
|
+
outcomes.append(None)
|
|
336
|
+
else:
|
|
337
|
+
outcomes.append(False)
|
|
338
|
+
if True in outcomes:
|
|
339
|
+
return True
|
|
340
|
+
if None in outcomes:
|
|
341
|
+
return None
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _package_path(profile_dir: Path, package_name: str) -> Path:
|
|
346
|
+
if package_name.startswith(".") or "\\" in package_name or "/../" in f"/{package_name}/":
|
|
347
|
+
raise ExportError(f"invalid package name for node_modules lookup: {package_name!r}")
|
|
348
|
+
return profile_dir / "node_modules" / Path(*package_name.split("/"))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _installed_package(profile_dir: Path, package_name: str) -> tuple[Path, dict[str, Any]]:
|
|
352
|
+
# DSH resolves Bundle layers from the installation anchor first and then
|
|
353
|
+
# from the Profile. Its healer exposes the installation dependency
|
|
354
|
+
# closure at ``$DSH_HOME/profiles/node_modules``. For a Profile under
|
|
355
|
+
# ``.../profiles/<name>``, that fallback is the parent node_modules
|
|
356
|
+
# directory. Keep the Profile-local lookup first so user-installed
|
|
357
|
+
# dependencies retain normal Node nearest-wins semantics.
|
|
358
|
+
package_paths = [_package_path(profile_dir, package_name)]
|
|
359
|
+
fallback_path = profile_dir.parent / "node_modules" / Path(*package_name.split("/"))
|
|
360
|
+
if fallback_path not in package_paths:
|
|
361
|
+
package_paths.append(fallback_path)
|
|
362
|
+
for package_dir in package_paths:
|
|
363
|
+
package_json = package_dir / "package.json"
|
|
364
|
+
if package_json.is_file():
|
|
365
|
+
return package_dir, _read_json_file(package_json, f"installed package {package_name}")
|
|
366
|
+
searched = ", ".join(str(path / "package.json") for path in package_paths)
|
|
367
|
+
raise ExportError(f"installed package manifest is missing; searched: {searched}")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _direct_package_dirs(root: Path) -> list[Path]:
|
|
371
|
+
if not root.is_dir():
|
|
372
|
+
return []
|
|
373
|
+
result: list[Path] = []
|
|
374
|
+
for entry in sorted(root.iterdir(), key=lambda path: path.name):
|
|
375
|
+
if entry.name.startswith(".") or not entry.is_dir():
|
|
376
|
+
continue
|
|
377
|
+
if entry.name.startswith("@"):
|
|
378
|
+
result.extend(
|
|
379
|
+
child
|
|
380
|
+
for child in sorted(entry.iterdir(), key=lambda path: path.name)
|
|
381
|
+
if not child.name.startswith(".") and child.is_dir()
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
result.append(entry)
|
|
385
|
+
return result
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _installed_bundle_inventory(profile_dir: Path) -> list[tuple[str, Path, str, dict[str, Any]]]:
|
|
389
|
+
"""Find direct Bundle packages in the Profile and DSH installation anchor.
|
|
390
|
+
|
|
391
|
+
This intentionally does not recurse through arbitrary dependency trees:
|
|
392
|
+
only packages directly exposed by either DSH resolution root are eligible
|
|
393
|
+
for an explicit "include installed Bundles" export.
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
roots = (
|
|
397
|
+
(profile_dir / "node_modules", "profile"),
|
|
398
|
+
(profile_dir.parent / "node_modules", "installation-anchor"),
|
|
399
|
+
)
|
|
400
|
+
found: list[tuple[str, Path, str, dict[str, Any]]] = []
|
|
401
|
+
names: set[str] = set()
|
|
402
|
+
for root, location in roots:
|
|
403
|
+
for package_dir in _direct_package_dirs(root):
|
|
404
|
+
try:
|
|
405
|
+
manifest = _read_json_file(package_dir / "package.json", f"installed package {package_dir.name}")
|
|
406
|
+
except ExportError:
|
|
407
|
+
continue
|
|
408
|
+
package_name = manifest.get("name")
|
|
409
|
+
package_version = manifest.get("version")
|
|
410
|
+
dsh = manifest.get("dsh")
|
|
411
|
+
bundle = dsh.get("bundle") if isinstance(dsh, dict) else None
|
|
412
|
+
patch = bundle.get("patch") if isinstance(bundle, dict) else None
|
|
413
|
+
if (
|
|
414
|
+
not isinstance(package_name, str)
|
|
415
|
+
or not package_name.strip()
|
|
416
|
+
or not isinstance(package_version, str)
|
|
417
|
+
or not package_version.strip()
|
|
418
|
+
or not isinstance(patch, str)
|
|
419
|
+
or not patch.strip()
|
|
420
|
+
or package_name in names
|
|
421
|
+
):
|
|
422
|
+
continue
|
|
423
|
+
names.add(package_name)
|
|
424
|
+
found.append((package_name, package_dir, location, manifest))
|
|
425
|
+
return found
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _profile_dependencies(profile_manifest: Mapping[str, Any]) -> dict[str, str]:
|
|
429
|
+
dependencies = profile_manifest.get("dependencies", {})
|
|
430
|
+
if not isinstance(dependencies, dict):
|
|
431
|
+
raise ExportError("Profile package.json dependencies must be an object")
|
|
432
|
+
result: dict[str, str] = {}
|
|
433
|
+
for name, specifier in dependencies.items():
|
|
434
|
+
result[_string(name, "Profile dependency name")] = _string(
|
|
435
|
+
specifier, f"Profile dependency {name!r}"
|
|
436
|
+
)
|
|
437
|
+
return result
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _profile_bundles(profile_manifest: Mapping[str, Any]) -> list[str]:
|
|
441
|
+
dsh = profile_manifest.get("dsh", {})
|
|
442
|
+
if dsh is None:
|
|
443
|
+
dsh = {}
|
|
444
|
+
if not isinstance(dsh, dict):
|
|
445
|
+
raise ExportError("Profile package.json dsh must be an object")
|
|
446
|
+
profile = dsh.get("profile", {})
|
|
447
|
+
if profile is None:
|
|
448
|
+
profile = {}
|
|
449
|
+
if not isinstance(profile, dict):
|
|
450
|
+
raise ExportError("Profile package.json dsh.profile must be an object")
|
|
451
|
+
bundles = profile.get("bundles", [])
|
|
452
|
+
if not isinstance(bundles, list) or any(not isinstance(name, str) or not name for name in bundles):
|
|
453
|
+
raise ExportError("Profile package.json dsh.profile.bundles must be an array of names")
|
|
454
|
+
if len(set(bundles)) != len(bundles):
|
|
455
|
+
raise ExportError("Profile package.json dsh.profile.bundles contains duplicates")
|
|
456
|
+
return list(bundles)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _profile_files(profile_dir: Path, destination: Path) -> dict[str, bytes]:
|
|
460
|
+
result: dict[str, bytes] = {}
|
|
461
|
+
resolved_destination = destination.resolve()
|
|
462
|
+
for path in profile_dir.rglob("*"):
|
|
463
|
+
if not path.is_file() or path.is_symlink():
|
|
464
|
+
continue
|
|
465
|
+
relative_path = path.relative_to(profile_dir)
|
|
466
|
+
if any(part in _SKIP_DIRS for part in relative_path.parts):
|
|
467
|
+
continue
|
|
468
|
+
if relative_path.name in _SKIP_FILES:
|
|
469
|
+
continue
|
|
470
|
+
if path.resolve() == resolved_destination:
|
|
471
|
+
continue
|
|
472
|
+
try:
|
|
473
|
+
content = path.read_bytes()
|
|
474
|
+
except OSError as error:
|
|
475
|
+
raise ExportError(f"cannot read Profile file: {path}") from error
|
|
476
|
+
if _SECRET_ASSIGNMENT_RE.search(content.decode("utf-8", errors="ignore")):
|
|
477
|
+
raise ExportError(f"secret-like value detected in Profile file: {relative_path.as_posix()}")
|
|
478
|
+
key = relative_path.as_posix()
|
|
479
|
+
validate_payload_key(key, "profile", "Profile files")
|
|
480
|
+
result[key] = content
|
|
481
|
+
if "package.json" not in result:
|
|
482
|
+
raise ExportError("Profile package.json was not collected")
|
|
483
|
+
return result
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _with_bundle_composition(profile_files: dict[str, bytes], bundles: Sequence[str]) -> None:
|
|
487
|
+
try:
|
|
488
|
+
manifest = json.loads(profile_files["package.json"].decode("utf-8"))
|
|
489
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
490
|
+
raise ExportError("Profile package.json cannot be rewritten for installed Bundles") from error
|
|
491
|
+
if not isinstance(manifest, dict):
|
|
492
|
+
raise ExportError("Profile package.json must contain an object")
|
|
493
|
+
dsh = manifest.get("dsh")
|
|
494
|
+
if dsh is None:
|
|
495
|
+
dsh = {}
|
|
496
|
+
manifest["dsh"] = dsh
|
|
497
|
+
if not isinstance(dsh, dict):
|
|
498
|
+
raise ExportError("Profile package.json dsh must be an object")
|
|
499
|
+
profile = dsh.get("profile")
|
|
500
|
+
if profile is None:
|
|
501
|
+
profile = {}
|
|
502
|
+
dsh["profile"] = profile
|
|
503
|
+
if not isinstance(profile, dict):
|
|
504
|
+
raise ExportError("Profile package.json dsh.profile must be an object")
|
|
505
|
+
profile["bundles"] = list(bundles)
|
|
506
|
+
profile_files["package.json"] = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _npm_command(configured: str | None) -> str:
|
|
510
|
+
if configured:
|
|
511
|
+
return configured
|
|
512
|
+
if os.name == "nt":
|
|
513
|
+
return shutil.which("npm.cmd") or shutil.which("npm") or "npm.cmd"
|
|
514
|
+
return shutil.which("npm") or "npm"
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _bundle_patch_path(package_dir: Path, package_name: str, patch: str) -> str:
|
|
518
|
+
normalized = posixpath.normpath(patch.replace("\\", "/"))
|
|
519
|
+
if normalized in {"", ".", ".."} or normalized.startswith("../") or normalized.startswith("/"):
|
|
520
|
+
raise ExportError(f"Bundle {package_name} has an unsafe dsh.bundle.patch path: {patch!r}")
|
|
521
|
+
package_root = package_dir.resolve()
|
|
522
|
+
patch_path = (package_dir / Path(*normalized.split("/"))).resolve()
|
|
523
|
+
try:
|
|
524
|
+
patch_path.relative_to(package_root)
|
|
525
|
+
except ValueError as error:
|
|
526
|
+
raise ExportError(f"Bundle {package_name} has an unsafe dsh.bundle.patch path: {patch!r}") from error
|
|
527
|
+
if not patch_path.is_file():
|
|
528
|
+
raise ExportError(f"Bundle {package_name} patch file is missing: {patch}")
|
|
529
|
+
return normalized
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _bundle_inserted_loader_ids(patch_text: str) -> list[str]:
|
|
535
|
+
"""Collect loader row ids registered via top-level ``insert`` blocks.
|
|
536
|
+
|
|
537
|
+
A DSH bundle patch is a YAML list. A top-level ``- insert:`` item
|
|
538
|
+
registers new loader rows (each indented ``- id:`` line), while a plain
|
|
539
|
+
top-level ``- id:`` item updates an existing row and is not a loader
|
|
540
|
+
registration. This scanner only understands that shape; nested lists
|
|
541
|
+
inside ``config`` are ignored.
|
|
542
|
+
"""
|
|
543
|
+
ids: list[str] = []
|
|
544
|
+
in_insert = False
|
|
545
|
+
insert_indent: int | None = None
|
|
546
|
+
for raw in patch_text.splitlines():
|
|
547
|
+
line = raw.rstrip()
|
|
548
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
549
|
+
continue
|
|
550
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
551
|
+
stripped = line.strip()
|
|
552
|
+
if indent == 0 and stripped.startswith("- "):
|
|
553
|
+
key = stripped[2:].strip()
|
|
554
|
+
in_insert = key == "insert:"
|
|
555
|
+
insert_indent = None
|
|
556
|
+
continue
|
|
557
|
+
if not in_insert:
|
|
558
|
+
continue
|
|
559
|
+
if not stripped.startswith("- "):
|
|
560
|
+
continue
|
|
561
|
+
if insert_indent is None:
|
|
562
|
+
insert_indent = indent
|
|
563
|
+
if indent != insert_indent:
|
|
564
|
+
continue
|
|
565
|
+
row = stripped[2:].strip()
|
|
566
|
+
if row.startswith("id:"):
|
|
567
|
+
value = row[3:].strip().strip("\"'")
|
|
568
|
+
if value:
|
|
569
|
+
ids.append(value)
|
|
570
|
+
return ids
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _assert_no_duplicate_loader_rows(profile_dir: Path, bundles: Sequence[str]) -> None:
|
|
574
|
+
"""Reject bundle compositions that duplicate a loader row.
|
|
575
|
+
|
|
576
|
+
DSH's plugin tree loader fails at boot with ``duplicate loader entry id``
|
|
577
|
+
when two bundles in one profile insert the same loader row (for example
|
|
578
|
+
``dsh-web-app`` and ``dsh-headless`` both insert ``code-runtime``). Detect
|
|
579
|
+
the collision while the Bundle patch files are still available instead of
|
|
580
|
+
shipping a Pack that cannot start.
|
|
581
|
+
"""
|
|
582
|
+
registered_by: dict[str, list[str]] = {}
|
|
583
|
+
for package_name in bundles:
|
|
584
|
+
try:
|
|
585
|
+
package_dir, package_manifest = _installed_package(profile_dir, package_name)
|
|
586
|
+
except ExportError:
|
|
587
|
+
continue
|
|
588
|
+
dsh = package_manifest.get("dsh") or {}
|
|
589
|
+
bundle_meta = dsh.get("bundle") or {}
|
|
590
|
+
patch = bundle_meta.get("patch")
|
|
591
|
+
if not isinstance(patch, str) or not patch.strip():
|
|
592
|
+
continue
|
|
593
|
+
try:
|
|
594
|
+
patch_relative = _bundle_patch_path(package_dir, package_name, patch)
|
|
595
|
+
except ExportError:
|
|
596
|
+
continue
|
|
597
|
+
patch_path = package_dir.joinpath(*patch_relative.split("/"))
|
|
598
|
+
try:
|
|
599
|
+
patch_text = patch_path.read_text(encoding="utf-8")
|
|
600
|
+
except (OSError, UnicodeDecodeError):
|
|
601
|
+
continue
|
|
602
|
+
for row_id in _bundle_inserted_loader_ids(patch_text):
|
|
603
|
+
registered_by.setdefault(row_id, []).append(package_name)
|
|
604
|
+
collisions = sorted(row_id for row_id, names in registered_by.items() if len(names) > 1)
|
|
605
|
+
if not collisions:
|
|
606
|
+
return
|
|
607
|
+
detail = "; ".join(
|
|
608
|
+
f"loader row {row_id!r} registered by {', '.join(registered_by[row_id])}"
|
|
609
|
+
for row_id in collisions
|
|
610
|
+
)
|
|
611
|
+
raise ExportError(
|
|
612
|
+
"Bundle composition duplicates a loader row and cannot boot: " + detail
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _validate_tarball(content: bytes, package_name: str, required_file: str | None = None) -> None:
|
|
617
|
+
from io import BytesIO
|
|
618
|
+
|
|
619
|
+
required_member = f"package/{required_file}" if required_file else None
|
|
620
|
+
found_required = False
|
|
621
|
+
try:
|
|
622
|
+
with tarfile.open(fileobj=BytesIO(content), mode="r:gz") as archive:
|
|
623
|
+
for member in archive.getmembers():
|
|
624
|
+
member_name = member.name.replace("\\", "/")
|
|
625
|
+
if any(part in {"node_modules", ".git", "cache", "logs"} for part in member_name.split("/")):
|
|
626
|
+
raise ExportError(f"npm pack artifact contains forbidden runtime path: {package_name}")
|
|
627
|
+
if member_name.startswith("/") or "/../" in f"/{member_name}/" or member_name.startswith("../"):
|
|
628
|
+
raise ExportError(f"npm pack artifact contains unsafe path: {package_name}")
|
|
629
|
+
if required_member == member_name and member.isfile():
|
|
630
|
+
found_required = True
|
|
631
|
+
except (tarfile.TarError, OSError) as error:
|
|
632
|
+
raise ExportError(f"npm pack did not produce a readable tarball: {package_name}") from error
|
|
633
|
+
if required_member and not found_required:
|
|
634
|
+
raise ExportError(f"npm pack artifact is missing Bundle patch file: {package_name}/{required_file}")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _npm_pack(
|
|
638
|
+
package_dir: Path,
|
|
639
|
+
package_name: str,
|
|
640
|
+
npm_command: str,
|
|
641
|
+
*,
|
|
642
|
+
required_file: str | None = None,
|
|
643
|
+
) -> bytes:
|
|
644
|
+
with tempfile.TemporaryDirectory(prefix="dsh-pack-npm-") as directory:
|
|
645
|
+
cache_directory = Path(directory) / "npm-cache"
|
|
646
|
+
cache_directory.mkdir()
|
|
647
|
+
command = [
|
|
648
|
+
npm_command,
|
|
649
|
+
"pack",
|
|
650
|
+
str(package_dir),
|
|
651
|
+
"--ignore-scripts",
|
|
652
|
+
"--pack-destination",
|
|
653
|
+
directory,
|
|
654
|
+
"--cache",
|
|
655
|
+
str(cache_directory),
|
|
656
|
+
]
|
|
657
|
+
completed = subprocess.run(
|
|
658
|
+
command,
|
|
659
|
+
cwd=package_dir,
|
|
660
|
+
capture_output=True,
|
|
661
|
+
text=True,
|
|
662
|
+
check=False,
|
|
663
|
+
timeout=120,
|
|
664
|
+
)
|
|
665
|
+
if completed.returncode != 0:
|
|
666
|
+
raise ExportError(
|
|
667
|
+
f"npm pack failed for {package_name} with exit code {completed.returncode}: "
|
|
668
|
+
f"{completed.stderr.strip() or completed.stdout.strip()}"
|
|
669
|
+
)
|
|
670
|
+
tarballs = sorted(Path(directory).glob("*.tgz"))
|
|
671
|
+
if len(tarballs) != 1:
|
|
672
|
+
raise ExportError(f"npm pack produced {len(tarballs)} tarballs for {package_name}")
|
|
673
|
+
content = tarballs[0].read_bytes()
|
|
674
|
+
_validate_tarball(content, package_name, required_file)
|
|
675
|
+
return content
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _artifact_name(package_name: str, version: str) -> str:
|
|
679
|
+
safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", package_name).strip(".-") or "plugin"
|
|
680
|
+
safe_version = re.sub(r"[^A-Za-z0-9._+-]+", "-", version).strip(".-") or "unknown"
|
|
681
|
+
return f"{safe_name}-{safe_version}.tgz"
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _plugin_lock_entry(
|
|
685
|
+
profile_dir: Path,
|
|
686
|
+
package_name: str,
|
|
687
|
+
specifier: str,
|
|
688
|
+
bundle_order: int | None,
|
|
689
|
+
options: PluginExportOptions,
|
|
690
|
+
npm_command: str,
|
|
691
|
+
runtime_source: str | None = None,
|
|
692
|
+
) -> tuple[dict[str, Any], dict[str, bytes]]:
|
|
693
|
+
package_dir, package_manifest = _installed_package(profile_dir, package_name)
|
|
694
|
+
installed_name = _string(package_manifest.get("name", package_name), f"{package_name}.package.json.name")
|
|
695
|
+
if installed_name != package_name:
|
|
696
|
+
raise ExportError(
|
|
697
|
+
f"installed package name mismatch: requested {package_name!r}, found {installed_name!r}"
|
|
698
|
+
)
|
|
699
|
+
version = _string(package_manifest.get("version"), f"{package_name}.package.json.version")
|
|
700
|
+
specifier = _effective_specifier(package_name, specifier, package_manifest)
|
|
701
|
+
source_type = _source_type(specifier)
|
|
702
|
+
if source_type == "registry":
|
|
703
|
+
semver_match = _registry_spec_satisfies(specifier, version)
|
|
704
|
+
if semver_match is False:
|
|
705
|
+
raise ExportError(
|
|
706
|
+
f"installed package {package_name}@{version} does not satisfy requested specifier {specifier!r}"
|
|
707
|
+
)
|
|
708
|
+
resolved_runtime_source = runtime_source or ("profile-dependency" if specifier else "installation-anchor")
|
|
709
|
+
dsh = package_manifest.get("dsh", {})
|
|
710
|
+
if dsh is None:
|
|
711
|
+
dsh = {}
|
|
712
|
+
if not isinstance(dsh, dict):
|
|
713
|
+
raise ExportError(f"{package_name}.package.json.dsh must be an object")
|
|
714
|
+
bundle_meta = dsh.get("bundle", {})
|
|
715
|
+
if bundle_meta is None:
|
|
716
|
+
bundle_meta = {}
|
|
717
|
+
if not isinstance(bundle_meta, dict):
|
|
718
|
+
raise ExportError(f"{package_name}.package.json.dsh.bundle must be an object")
|
|
719
|
+
patch = bundle_meta.get("patch")
|
|
720
|
+
patch_relative_path: str | None = None
|
|
721
|
+
if bundle_order is not None and (not isinstance(patch, str) or not patch.strip()):
|
|
722
|
+
raise ExportError(f"Bundle {package_name} is missing dsh.bundle.patch")
|
|
723
|
+
if bundle_order is not None:
|
|
724
|
+
patch_relative_path = _bundle_patch_path(package_dir, package_name, patch)
|
|
725
|
+
|
|
726
|
+
entry: dict[str, Any] = {
|
|
727
|
+
"name": package_name,
|
|
728
|
+
"required": options.required,
|
|
729
|
+
"requested": {"specifier": specifier, "sourceType": source_type},
|
|
730
|
+
"resolved": {
|
|
731
|
+
"version": version,
|
|
732
|
+
"locator": f"registry:{package_name}@{version}" if source_type == "registry" else specifier,
|
|
733
|
+
},
|
|
734
|
+
"runtime": {
|
|
735
|
+
"version": version,
|
|
736
|
+
"source": resolved_runtime_source,
|
|
737
|
+
"entry": dsh.get("runtime", {}).get("entry", package_name)
|
|
738
|
+
if isinstance(dsh.get("runtime", {}), dict)
|
|
739
|
+
else package_name,
|
|
740
|
+
},
|
|
741
|
+
"bundle": {
|
|
742
|
+
"enabled": bundle_order is not None,
|
|
743
|
+
"order": bundle_order,
|
|
744
|
+
"patch": patch if bundle_order is not None else None,
|
|
745
|
+
},
|
|
746
|
+
"artifact": {"mode": options.mode},
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
artifacts: dict[str, bytes] = {}
|
|
750
|
+
if options.mode == EMBEDDED:
|
|
751
|
+
content = _npm_pack(
|
|
752
|
+
package_dir,
|
|
753
|
+
package_name,
|
|
754
|
+
npm_command,
|
|
755
|
+
required_file=patch_relative_path,
|
|
756
|
+
)
|
|
757
|
+
artifact_file = _artifact_name(package_name, version)
|
|
758
|
+
artifact_path = f"plugins/{artifact_file}"
|
|
759
|
+
entry["artifact"] = {
|
|
760
|
+
"mode": EMBEDDED,
|
|
761
|
+
"path": artifact_path,
|
|
762
|
+
"sha256": sha256_bytes(content),
|
|
763
|
+
}
|
|
764
|
+
artifacts[artifact_file] = content
|
|
765
|
+
return entry, artifacts
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def export_profile(
|
|
769
|
+
profile: str | os.PathLike[str],
|
|
770
|
+
destination: str | os.PathLike[str],
|
|
771
|
+
*,
|
|
772
|
+
options: ExportOptions,
|
|
773
|
+
) -> ExportResult:
|
|
774
|
+
"""Export one existing Profile directory into a `.dshcrate`.
|
|
775
|
+
|
|
776
|
+
The Profile is read only. The only generated external files are the
|
|
777
|
+
destination Pack and temporary local npm tarballs used for embedded mode.
|
|
778
|
+
"""
|
|
779
|
+
|
|
780
|
+
profile_dir = Path(profile).resolve()
|
|
781
|
+
destination_path = Path(destination).resolve()
|
|
782
|
+
if not profile_dir.is_dir():
|
|
783
|
+
raise ExportError(f"Profile directory does not exist: {profile_dir}")
|
|
784
|
+
if profile_dir == destination_path or profile_dir in destination_path.parents:
|
|
785
|
+
raise ExportError("destination Pack must be outside the source Profile directory")
|
|
786
|
+
|
|
787
|
+
profile_manifest = _read_json_file(profile_dir / "package.json", "Profile package.json")
|
|
788
|
+
dependencies = _profile_dependencies(profile_manifest)
|
|
789
|
+
bundles = _profile_bundles(profile_manifest)
|
|
790
|
+
plugin_options = dict(options.plugin_options)
|
|
791
|
+
extra_runtime_sources: dict[str, str] = {}
|
|
792
|
+
if options.include_installation_bundles:
|
|
793
|
+
installed_inventory = _installed_bundle_inventory(profile_dir)
|
|
794
|
+
installed_names = {package_name for package_name, *_ in installed_inventory}
|
|
795
|
+
for package_name, _package_dir, location, _package_manifest in installed_inventory:
|
|
796
|
+
if package_name in plugin_options and package_name not in bundles:
|
|
797
|
+
bundles.append(package_name)
|
|
798
|
+
extra_runtime_sources[package_name] = (
|
|
799
|
+
"profile-dependency" if location == "profile" else "installation-anchor"
|
|
800
|
+
)
|
|
801
|
+
else:
|
|
802
|
+
installed_names = set()
|
|
803
|
+
plugin_names = list(bundles)
|
|
804
|
+
plugin_names.extend(name for name in dependencies if name not in plugin_names)
|
|
805
|
+
if not plugin_names:
|
|
806
|
+
raise ExportError("Profile has no dependencies or dsh.profile.bundles")
|
|
807
|
+
_assert_no_duplicate_loader_rows(profile_dir, bundles)
|
|
808
|
+
|
|
809
|
+
unknown_options = set(plugin_options) - set(plugin_names) - installed_names
|
|
810
|
+
if unknown_options:
|
|
811
|
+
raise ExportError(f"plugin options refer to packages absent from Profile: {sorted(unknown_options)}")
|
|
812
|
+
|
|
813
|
+
npm_command = _npm_command(options.npm_command)
|
|
814
|
+
profile_files = _profile_files(profile_dir, destination_path)
|
|
815
|
+
if options.include_installation_bundles:
|
|
816
|
+
_with_bundle_composition(profile_files, bundles)
|
|
817
|
+
plugins: list[dict[str, Any]] = []
|
|
818
|
+
artifacts: dict[str, bytes] = {}
|
|
819
|
+
for package_name in plugin_names:
|
|
820
|
+
specifier = dependencies.get(package_name, "unknown")
|
|
821
|
+
selected = plugin_options.get(package_name, PluginExportOptions())
|
|
822
|
+
entry, package_artifacts = _plugin_lock_entry(
|
|
823
|
+
profile_dir,
|
|
824
|
+
package_name,
|
|
825
|
+
specifier,
|
|
826
|
+
bundles.index(package_name) if package_name in bundles else None,
|
|
827
|
+
selected,
|
|
828
|
+
npm_command,
|
|
829
|
+
extra_runtime_sources.get(package_name),
|
|
830
|
+
)
|
|
831
|
+
plugins.append(entry)
|
|
832
|
+
for artifact_name, content in package_artifacts.items():
|
|
833
|
+
if artifact_name in artifacts:
|
|
834
|
+
raise ExportError(f"duplicate generated artifact filename: {artifact_name}")
|
|
835
|
+
artifacts[artifact_name] = content
|
|
836
|
+
|
|
837
|
+
required_secrets = tuple(options.required_secrets) or _imported_required_secrets(profile_dir)
|
|
838
|
+
manifest = {
|
|
839
|
+
"schemaVersion": 1,
|
|
840
|
+
"format": "dshcrate",
|
|
841
|
+
"packVersion": "0.1.0",
|
|
842
|
+
"profile": {"name": profile_dir.name},
|
|
843
|
+
"environment": copy.deepcopy(dict(options.environment)),
|
|
844
|
+
"requiredSecrets": list(required_secrets),
|
|
845
|
+
}
|
|
846
|
+
data = PackData(
|
|
847
|
+
manifest=manifest,
|
|
848
|
+
profile_files=profile_files,
|
|
849
|
+
plugins_lock={"schemaVersion": 1, "plugins": plugins},
|
|
850
|
+
plugin_artifacts=artifacts,
|
|
851
|
+
)
|
|
852
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
853
|
+
created = create(data, destination_path)
|
|
854
|
+
return ExportResult(path=created, data=data)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _load_environment(args: argparse.Namespace) -> dict[str, Any]:
|
|
858
|
+
if args.environment_json:
|
|
859
|
+
value = _read_json_file(Path(args.environment_json), "environment JSON")
|
|
860
|
+
return value
|
|
861
|
+
return {
|
|
862
|
+
"schemaVersion": 1,
|
|
863
|
+
"os": {"name": args.os_name, "version": args.os_version, "arch": args.os_arch},
|
|
864
|
+
"node": {"version": args.node_version},
|
|
865
|
+
"dsh": {"version": args.dsh_version},
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
870
|
+
parser = argparse.ArgumentParser(description="Export an existing DSH Profile into a .dshcrate")
|
|
871
|
+
parser.add_argument("--profile", required=True, help="Profile directory")
|
|
872
|
+
parser.add_argument("--output", required=True, help="Output .dshcrate path")
|
|
873
|
+
parser.add_argument("--environment-json", help="JSON object containing Pack environment")
|
|
874
|
+
parser.add_argument("--dsh-version", default="unknown")
|
|
875
|
+
parser.add_argument("--node-version", default="unknown")
|
|
876
|
+
parser.add_argument("--os-name", default="unknown")
|
|
877
|
+
parser.add_argument("--os-version", default="unknown")
|
|
878
|
+
parser.add_argument("--os-arch", default="unknown")
|
|
879
|
+
parser.add_argument("--embed", action="append", default=[], metavar="PLUGIN")
|
|
880
|
+
parser.add_argument("--reference-only", action="append", default=[], metavar="PLUGIN")
|
|
881
|
+
parser.add_argument("--include-installed-bundles", action="store_true")
|
|
882
|
+
parser.add_argument("--required-secret", action="append", default=[], metavar="NAME")
|
|
883
|
+
args = parser.parse_args(argv)
|
|
884
|
+
|
|
885
|
+
overlap = set(args.embed) & set(args.reference_only)
|
|
886
|
+
if overlap:
|
|
887
|
+
parser.error(f"plugin selected for both --embed and --reference-only: {sorted(overlap)}")
|
|
888
|
+
plugin_options = {
|
|
889
|
+
name: PluginExportOptions(mode=EMBEDDED)
|
|
890
|
+
for name in args.embed
|
|
891
|
+
}
|
|
892
|
+
plugin_options.update({name: PluginExportOptions(mode=REFERENCE_ONLY) for name in args.reference_only})
|
|
893
|
+
try:
|
|
894
|
+
result = export_profile(
|
|
895
|
+
args.profile,
|
|
896
|
+
args.output,
|
|
897
|
+
options=ExportOptions(
|
|
898
|
+
environment=_load_environment(args),
|
|
899
|
+
required_secrets=tuple(args.required_secret),
|
|
900
|
+
plugin_options=plugin_options,
|
|
901
|
+
include_installation_bundles=args.include_installed_bundles,
|
|
902
|
+
),
|
|
903
|
+
)
|
|
904
|
+
except DshPackError as error:
|
|
905
|
+
print(f"EXPORT FAIL: {error}")
|
|
906
|
+
return 2
|
|
907
|
+
print(f"EXPORT PASS: {result.path}")
|
|
908
|
+
return 0
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
if __name__ == "__main__":
|
|
912
|
+
raise SystemExit(main())
|