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.
Files changed (57) hide show
  1. package/README.md +302 -0
  2. package/README.zh-CN.md +304 -0
  3. package/cordis.patch.yml +11 -0
  4. package/core/dsh_pack/__init__.py +77 -0
  5. package/core/dsh_pack/__main__.py +4 -0
  6. package/core/dsh_pack/cli.py +372 -0
  7. package/core/dsh_pack/errors.py +167 -0
  8. package/core/dsh_pack/export.py +912 -0
  9. package/core/dsh_pack/importer.py +1347 -0
  10. package/core/dsh_pack/network.py +77 -0
  11. package/core/dsh_pack/pack.py +248 -0
  12. package/core/dsh_pack/preflight.py +811 -0
  13. package/core/dsh_pack/user_cli.py +462 -0
  14. package/core/dsh_pack/validation.py +272 -0
  15. package/core/dsh_pack/verify.py +1336 -0
  16. package/lib/client.js +1623 -0
  17. package/lib/index.d.ts +30 -0
  18. package/lib/index.js +1275 -0
  19. package/package.json +73 -0
  20. package/restart-helper.mjs +121 -0
  21. package/runtime/python/LICENSE.txt +676 -0
  22. package/runtime/python/__install__.json +82 -0
  23. package/runtime/python/_asyncio.pyd +0 -0
  24. package/runtime/python/_bz2.pyd +0 -0
  25. package/runtime/python/_ctypes.pyd +0 -0
  26. package/runtime/python/_decimal.pyd +0 -0
  27. package/runtime/python/_elementtree.pyd +0 -0
  28. package/runtime/python/_hashlib.pyd +0 -0
  29. package/runtime/python/_lzma.pyd +0 -0
  30. package/runtime/python/_multiprocessing.pyd +0 -0
  31. package/runtime/python/_overlapped.pyd +0 -0
  32. package/runtime/python/_queue.pyd +0 -0
  33. package/runtime/python/_remote_debugging.pyd +0 -0
  34. package/runtime/python/_socket.pyd +0 -0
  35. package/runtime/python/_sqlite3.pyd +0 -0
  36. package/runtime/python/_ssl.pyd +0 -0
  37. package/runtime/python/_uuid.pyd +0 -0
  38. package/runtime/python/_wmi.pyd +0 -0
  39. package/runtime/python/_zoneinfo.pyd +0 -0
  40. package/runtime/python/_zstd.pyd +0 -0
  41. package/runtime/python/libcrypto-3.dll +0 -0
  42. package/runtime/python/libffi-8.dll +0 -0
  43. package/runtime/python/libssl-3.dll +0 -0
  44. package/runtime/python/pyexpat.pyd +0 -0
  45. package/runtime/python/python.cat +0 -0
  46. package/runtime/python/python.exe +0 -0
  47. package/runtime/python/python3.dll +0 -0
  48. package/runtime/python/python314._pth +6 -0
  49. package/runtime/python/python314.dll +0 -0
  50. package/runtime/python/python314.zip +0 -0
  51. package/runtime/python/pythonw.exe +0 -0
  52. package/runtime/python/select.pyd +0 -0
  53. package/runtime/python/sqlite3.dll +0 -0
  54. package/runtime/python/unicodedata.pyd +0 -0
  55. package/runtime/python/vcruntime140.dll +0 -0
  56. package/runtime/python/vcruntime140_1.dll +0 -0
  57. 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())