dsh-crate-web 0.1.0 → 0.1.2-preview.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +11 -290
- package/README.zh-CN.md +2 -303
- package/THIRD-PARTY-LICENSE.txt +21 -0
- package/cordis.patch.yml +1 -6
- package/core/dsh_pack/_version.py +1 -0
- package/core/dsh_pack/bundle.py +74 -0
- package/core/dsh_pack/cli.py +81 -18
- package/core/dsh_pack/desktop_verify.py +304 -0
- package/core/dsh_pack/diagnostics.py +1170 -0
- package/core/dsh_pack/errors.py +16 -1
- package/core/dsh_pack/export.py +86 -103
- package/core/dsh_pack/export_plan.py +96 -0
- package/core/dsh_pack/identity.py +25 -0
- package/core/dsh_pack/importer.py +1598 -1347
- package/core/dsh_pack/network.py +5 -4
- package/core/dsh_pack/pack.py +14 -2
- package/core/dsh_pack/portability.py +392 -0
- package/core/dsh_pack/preflight.py +63 -16
- package/core/dsh_pack/troubleshooting.py +94 -0
- package/core/dsh_pack/user_cli.py +16 -0
- package/core/dsh_pack/validation.py +64 -4
- package/core/dsh_pack/verify.py +1485 -1336
- package/core/dsh_pack/windows_verify.py +177 -0
- package/desktop-patch-helper.mjs +140 -0
- package/lib/client.js +292 -102
- package/lib/index.d.ts +7 -1
- package/lib/index.js +1121 -229
- package/package.json +13 -5
- 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
- package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
- package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
- package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
- package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
- package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
- package/verify-desktop.mjs +314 -0
|
@@ -12,6 +12,7 @@ from typing import Any, Mapping
|
|
|
12
12
|
|
|
13
13
|
from .errors import DshPackError, IntegrityError, SchemaValidationError, _default_checks
|
|
14
14
|
from .network import network_source_details
|
|
15
|
+
from .identity import expected_install_version, unknown_source
|
|
15
16
|
from .pack import _read_entries, sha256_bytes
|
|
16
17
|
from .validation import validate_manifest, validate_plugins_lock
|
|
17
18
|
|
|
@@ -47,7 +48,7 @@ _EXPECTED_BY_CODE = {
|
|
|
47
48
|
"HASH_MISMATCH": "declared SHA-256 matches artifact bytes",
|
|
48
49
|
"INTEGRITY_FILE_MISSING": "all declared Pack files are present",
|
|
49
50
|
"INTEGRITY_FILE_SET_MISMATCH": "Pack files exactly match the integrity set",
|
|
50
|
-
"VERSION_MISSING": "each plugin has
|
|
51
|
+
"VERSION_MISSING": "each plugin has a known resolution or an explicit unknown resolution with known runtime identity",
|
|
51
52
|
"PACKAGE_IDENTITY_DRIFT": "artifact package identity matches lock identity",
|
|
52
53
|
"REQUIRED_ARTIFACT_MISSING": "required embedded artifact is present",
|
|
53
54
|
"OPTIONAL_ARTIFACT_MISSING": "optional embedded artifact is present",
|
|
@@ -70,7 +71,7 @@ _NEXT_STEP_BY_CODE = {
|
|
|
70
71
|
"HASH_MISMATCH": "Restore the original artifact or regenerate the Pack.",
|
|
71
72
|
"INTEGRITY_FILE_MISSING": "Restore the missing Pack member and regenerate its integrity table.",
|
|
72
73
|
"INTEGRITY_FILE_SET_MISMATCH": "Regenerate the Pack so its integrity file set matches its contents.",
|
|
73
|
-
"VERSION_MISSING": "
|
|
74
|
+
"VERSION_MISSING": "Re-export runtime identity, or provide recorded resolution evidence without guessing it.",
|
|
74
75
|
"PACKAGE_IDENTITY_DRIFT": "Restore the artifact matching plugins.lock.json or re-export the Profile.",
|
|
75
76
|
"REQUIRED_ARTIFACT_MISSING": "Restore the required embedded artifact before continuing.",
|
|
76
77
|
"OPTIONAL_ARTIFACT_MISSING": "Restore the optional artifact or intentionally export the plugin reference-only.",
|
|
@@ -108,7 +109,9 @@ class Finding:
|
|
|
108
109
|
raise ValueError(f"unsupported preflight severity: {self.severity}")
|
|
109
110
|
|
|
110
111
|
def as_dict(self) -> dict[str, Any]:
|
|
112
|
+
from .diagnostics import envelope_fields
|
|
111
113
|
return {
|
|
114
|
+
**envelope_fields(operation="inspect", status="FAIL" if self.severity == "BLOCKER" else "UNTESTED"),
|
|
112
115
|
"severity": self.severity,
|
|
113
116
|
"code": self.code,
|
|
114
117
|
"message": self.message,
|
|
@@ -138,6 +141,7 @@ class PreflightContext:
|
|
|
138
141
|
available_secrets: set[str] | None = None
|
|
139
142
|
available_plugins: Mapping[str, str | None] | None = None
|
|
140
143
|
allow_network_reference_install: bool = False
|
|
144
|
+
verified_local_plugins: Mapping[str, str] = field(default_factory=dict)
|
|
141
145
|
|
|
142
146
|
|
|
143
147
|
@dataclass
|
|
@@ -384,7 +388,9 @@ def inspect_pack(
|
|
|
384
388
|
|
|
385
389
|
try:
|
|
386
390
|
validate_manifest(manifest, require_integrity=True)
|
|
387
|
-
|
|
391
|
+
from .portability import validate_inventory
|
|
392
|
+
validate_inventory(manifest, {name[len("user-data/"):]: content for name, content in entries.items() if name.startswith("user-data/")}, lock)
|
|
393
|
+
except DshPackError as error:
|
|
388
394
|
findings.append(_finding("BLOCKER", "SCHEMA_ERROR", str(error)))
|
|
389
395
|
|
|
390
396
|
raw_plugins = lock.get("plugins", [])
|
|
@@ -400,15 +406,14 @@ def inspect_pack(
|
|
|
400
406
|
if name in plugin_names:
|
|
401
407
|
findings.append(_finding("BLOCKER", "SCHEMA_ERROR", f"duplicate plugin name: {name}"))
|
|
402
408
|
plugin_names.add(name)
|
|
403
|
-
|
|
404
|
-
version = resolved.get("version") if isinstance(resolved, Mapping) else None
|
|
409
|
+
version = expected_install_version(raw_plugin)
|
|
405
410
|
if not isinstance(version, str) or not version.strip():
|
|
406
411
|
missing_version_indexes.add(index)
|
|
407
412
|
findings.append(
|
|
408
413
|
_finding(
|
|
409
414
|
"BLOCKER",
|
|
410
415
|
"VERSION_MISSING",
|
|
411
|
-
f"plugin at plugins.lock.plugins[{index}]
|
|
416
|
+
f"plugin at plugins.lock.plugins[{index}] has no usable installation version identity",
|
|
412
417
|
index=index,
|
|
413
418
|
plugin=name,
|
|
414
419
|
)
|
|
@@ -466,7 +471,9 @@ def inspect_pack(
|
|
|
466
471
|
|
|
467
472
|
try:
|
|
468
473
|
validate_plugins_lock(lock, artifact_paths=artifact_entries)
|
|
469
|
-
|
|
474
|
+
from .validation import validate_format_pair
|
|
475
|
+
validate_format_pair(manifest, lock)
|
|
476
|
+
except DshPackError as error:
|
|
470
477
|
message = str(error)
|
|
471
478
|
if "resolved.version" not in message and "resolved is missing fields" not in message:
|
|
472
479
|
if "artifact.path is missing from plugins/" not in message:
|
|
@@ -594,9 +601,9 @@ def inspect_pack(
|
|
|
594
601
|
if not _version_matches(required, current):
|
|
595
602
|
findings.append(
|
|
596
603
|
_finding(
|
|
597
|
-
"
|
|
604
|
+
"BLOCKER",
|
|
598
605
|
"ENVIRONMENT_VERSION_MISMATCH",
|
|
599
|
-
f"Pack
|
|
606
|
+
f"Pack requires {label} {required}; current is {current}",
|
|
600
607
|
stage="environment",
|
|
601
608
|
item=section,
|
|
602
609
|
expected=required,
|
|
@@ -636,17 +643,35 @@ def inspect_pack(
|
|
|
636
643
|
for name in missing_secrets:
|
|
637
644
|
findings.append(_finding("WARNING", "SECRET_MISSING", f"required Secret is missing: {name}", secret=name))
|
|
638
645
|
|
|
646
|
+
for plugin in plugin_summaries:
|
|
647
|
+
artifact = plugin.get("artifact")
|
|
648
|
+
if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only" or not unknown_source(plugin):
|
|
649
|
+
continue
|
|
650
|
+
expected_version = expected_install_version(plugin)
|
|
651
|
+
local_version = context.verified_local_plugins.get(plugin["name"])
|
|
652
|
+
if expected_version is None or local_version != expected_version:
|
|
653
|
+
findings.append(_finding(
|
|
654
|
+
"BLOCKER" if plugin.get("required") is True else "WARNING", "SOURCE_UNKNOWN",
|
|
655
|
+
f"reference-only plugin has no recorded installation source: {plugin['name']}",
|
|
656
|
+
stage="identity", plugin=plugin["name"], expected=expected_version,
|
|
657
|
+
observed=local_version, evidence={"requested": plugin.get("requested"),
|
|
658
|
+
"resolved": plugin.get("resolved"), "runtime": plugin.get("runtime")},
|
|
659
|
+
suggested_next_step="Provide an explicit local package source with matching name/version, or embed its verified artifact.",
|
|
660
|
+
))
|
|
661
|
+
|
|
639
662
|
if context.available_plugins is not None:
|
|
640
663
|
available_plugins = dict(context.available_plugins)
|
|
641
664
|
for plugin in plugin_summaries:
|
|
642
665
|
artifact = plugin.get("artifact")
|
|
643
666
|
if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only":
|
|
644
667
|
continue
|
|
668
|
+
if unknown_source(plugin):
|
|
669
|
+
continue # Checked separately using verified local-source evidence.
|
|
645
670
|
name = plugin["name"]
|
|
646
671
|
if name not in available_plugins:
|
|
647
672
|
network_source = network_source_details(plugin)
|
|
648
673
|
if context.allow_network_reference_install and network_source is not None:
|
|
649
|
-
expected_version =
|
|
674
|
+
expected_version = expected_install_version(plugin)
|
|
650
675
|
findings.append(
|
|
651
676
|
_finding(
|
|
652
677
|
"WARNING",
|
|
@@ -670,7 +695,7 @@ def inspect_pack(
|
|
|
670
695
|
findings.append(_finding(severity, code, f"reference-only plugin is missing: {name}", plugin=name))
|
|
671
696
|
continue
|
|
672
697
|
installed_version = available_plugins[name]
|
|
673
|
-
expected_version =
|
|
698
|
+
expected_version = expected_install_version(plugin)
|
|
674
699
|
if installed_version and expected_version and installed_version != expected_version:
|
|
675
700
|
findings.append(
|
|
676
701
|
_finding(
|
|
@@ -690,12 +715,32 @@ def inspect_pack(
|
|
|
690
715
|
path = artifact.get("path")
|
|
691
716
|
if not isinstance(path, str) or path not in entries:
|
|
692
717
|
continue
|
|
718
|
+
bundle = plugin.get("bundle")
|
|
719
|
+
# Schema 3 adds exhaustive ordered-patch artifact validation. Older
|
|
720
|
+
# schemas retain their previous Preflight contract; Import still checks
|
|
721
|
+
# the actual installed patch declarations and contents.
|
|
722
|
+
if (manifest.get("schemaVersion") == 3
|
|
723
|
+
and isinstance(bundle, Mapping) and bundle.get("enabled") is True):
|
|
724
|
+
from .bundle import check_bundle_artifact
|
|
725
|
+
try:
|
|
726
|
+
check_bundle_artifact(entries[path], bundle.get("patch"))
|
|
727
|
+
except DshPackError as error:
|
|
728
|
+
findings.append(_finding(
|
|
729
|
+
"BLOCKER" if plugin.get("required") is True else "WARNING",
|
|
730
|
+
"BUNDLE_PATCH_INVALID", f"embedded Bundle patch check failed: {plugin['name']}",
|
|
731
|
+
plugin=plugin["name"], error=str(error), patch=bundle.get("patch"),
|
|
732
|
+
))
|
|
693
733
|
identity = _artifact_package_identity(entries[path])
|
|
734
|
+
resolved = plugin.get("resolved")
|
|
735
|
+
resolution_unknown = isinstance(resolved, Mapping) and resolved.get("version") == "unknown"
|
|
694
736
|
if identity is None:
|
|
737
|
+
if resolution_unknown:
|
|
738
|
+
findings.append(_finding("BLOCKER" if plugin.get("required") is True else "WARNING",
|
|
739
|
+
"PACKAGE_IDENTITY_DRIFT", "embedded package identity cannot be verified",
|
|
740
|
+
plugin=plugin["name"]))
|
|
695
741
|
continue
|
|
696
742
|
plugin["artifactIdentity"] = identity
|
|
697
|
-
|
|
698
|
-
expected_version = resolved.get("version") if isinstance(resolved, Mapping) else None
|
|
743
|
+
expected_version = expected_install_version(plugin)
|
|
699
744
|
drift: dict[str, dict[str, str]] = {}
|
|
700
745
|
if identity["name"] != plugin["name"]:
|
|
701
746
|
drift["name"] = {"expected": str(plugin["name"]), "actual": identity["name"]}
|
|
@@ -714,7 +759,8 @@ def inspect_pack(
|
|
|
714
759
|
if drift:
|
|
715
760
|
findings.append(
|
|
716
761
|
_finding(
|
|
717
|
-
"WARNING"
|
|
762
|
+
("BLOCKER" if plugin.get("required") is True else "WARNING")
|
|
763
|
+
if resolution_unknown else "WARNING",
|
|
718
764
|
"PACKAGE_IDENTITY_DRIFT",
|
|
719
765
|
f"embedded package identity differs from lock identity: {plugin['name']}",
|
|
720
766
|
plugin=plugin["name"],
|
|
@@ -738,6 +784,8 @@ def inspect_pack(
|
|
|
738
784
|
"path": str(source_path),
|
|
739
785
|
"format": manifest.get("format"),
|
|
740
786
|
"packVersion": manifest.get("packVersion"),
|
|
787
|
+
"dataInventory": manifest.get("dataInventory", []),
|
|
788
|
+
"completeness": manifest.get("completeness", {}),
|
|
741
789
|
"profile": manifest.get("profile"),
|
|
742
790
|
},
|
|
743
791
|
schema={"status": schema_status, "version": schema_version},
|
|
@@ -778,8 +826,7 @@ def render_text(result: PreflightResult) -> str:
|
|
|
778
826
|
]
|
|
779
827
|
if result.plugins:
|
|
780
828
|
for plugin in result.plugins:
|
|
781
|
-
|
|
782
|
-
version = resolved.get("version", "missing") if isinstance(resolved, Mapping) else "missing"
|
|
829
|
+
version = expected_install_version(plugin) or "unknown"
|
|
783
830
|
required = "required" if plugin.get("required") else "optional"
|
|
784
831
|
artifact = plugin.get("artifact")
|
|
785
832
|
mode = artifact.get("mode", "unknown") if isinstance(artifact, Mapping) else "unknown"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Read-only diagnosis using the release's existing diagnostic registry."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Sequence
|
|
9
|
+
|
|
10
|
+
from .diagnostics import CRATE_VERSION, classify, compat_status
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _issues(value: Any):
|
|
14
|
+
if isinstance(value, list):
|
|
15
|
+
for item in value:
|
|
16
|
+
yield from _issues(item)
|
|
17
|
+
elif isinstance(value, dict):
|
|
18
|
+
if isinstance(value.get("code"), str):
|
|
19
|
+
yield value
|
|
20
|
+
# Only traverse report containers, never execute or interpret arbitrary
|
|
21
|
+
# command strings, package configuration or model instructions.
|
|
22
|
+
for key in ("result", "error", "errors", "diagnostic", "diagnostics", "findings", "steps"):
|
|
23
|
+
if key in value:
|
|
24
|
+
yield from _issues(value[key])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def diagnose(report: dict[str, Any]) -> dict[str, Any]:
|
|
28
|
+
if not isinstance(report, dict):
|
|
29
|
+
raise ValueError("diagnostic report must be a JSON object")
|
|
30
|
+
version = report.get("crateVersion")
|
|
31
|
+
schema = report.get("diagnosticSchemaVersion")
|
|
32
|
+
if (report.get("producer") != "dsh-crate" or not isinstance(version, str)
|
|
33
|
+
or type(schema) is not int):
|
|
34
|
+
compatibility = {"status": "UNSUPPORTED", "canProceed": False,
|
|
35
|
+
"reason": "diagnostic envelope is incomplete"}
|
|
36
|
+
else:
|
|
37
|
+
compatibility = compat_status(
|
|
38
|
+
skill_version=CRATE_VERSION, dsh_crate_range=">=0.1.0 <0.1.1",
|
|
39
|
+
supported_schemas=[1], crate_version=version,
|
|
40
|
+
diagnostic_schema_version=schema,
|
|
41
|
+
)
|
|
42
|
+
issues = []
|
|
43
|
+
for issue in _issues(report):
|
|
44
|
+
matched = classify(issue)
|
|
45
|
+
# A registry entry suggests checks, not a proven root cause. Actual
|
|
46
|
+
# severity, stage and evidence always remain available unchanged.
|
|
47
|
+
issues.append({
|
|
48
|
+
"code": issue["code"], "stage": issue.get("stage"),
|
|
49
|
+
"severity": issue.get("severity"), "known": matched["known"],
|
|
50
|
+
"suggestedChecks": matched.get("suggestedChecks", []),
|
|
51
|
+
"registry": matched, "evidence": copy.deepcopy(issue),
|
|
52
|
+
})
|
|
53
|
+
supported = compatibility["canProceed"] and bool(issues) and all(i["known"] for i in issues)
|
|
54
|
+
return {
|
|
55
|
+
"operation": "diagnose", "readOnly": True, "writeScope": "none",
|
|
56
|
+
"compatibility": compatibility, "sourceStatus": report.get("status"),
|
|
57
|
+
"decision": "REVIEW_EVIDENCE" if supported else "STOP",
|
|
58
|
+
"repairStatus": "UNTESTED", "issues": issues,
|
|
59
|
+
"nextStep": (
|
|
60
|
+
"Check the listed evidence, propose the smallest scoped correction, then rerun Verify."
|
|
61
|
+
if supported else
|
|
62
|
+
"Obtain a complete report from the matching release; unknown evidence must not trigger repair."
|
|
63
|
+
),
|
|
64
|
+
"sourceReport": copy.deepcopy(report),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
69
|
+
parser = argparse.ArgumentParser(prog="dsh-crate diagnose")
|
|
70
|
+
parser.add_argument("report", help="complete diagnostic JSON file")
|
|
71
|
+
parser.add_argument("--json", action="store_true")
|
|
72
|
+
args = parser.parse_args(argv)
|
|
73
|
+
try:
|
|
74
|
+
result = diagnose(json.loads(Path(args.report).read_text(encoding="utf-8")))
|
|
75
|
+
except (OSError, ValueError) as error:
|
|
76
|
+
result = {"operation": "diagnose", "readOnly": True, "decision": "STOP",
|
|
77
|
+
"repairStatus": "UNTESTED", "error": str(error)}
|
|
78
|
+
if args.json:
|
|
79
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
80
|
+
else:
|
|
81
|
+
print(f"Diagnosis: {result['decision']} (read-only)")
|
|
82
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
83
|
+
return 0 if result["decision"] == "REVIEW_EVIDENCE" else 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
raise SystemExit(main())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def configure_parser(subparsers):
|
|
91
|
+
parser = subparsers.add_parser("diagnose", help="read-only diagnosis from a complete report")
|
|
92
|
+
parser.add_argument("report")
|
|
93
|
+
parser.add_argument("--json", action="store_true")
|
|
94
|
+
parser.set_defaults(handler=lambda args: main([args.report] + (["--json"] if args.json else [])))
|
|
@@ -114,7 +114,9 @@ def _envelope(
|
|
|
114
114
|
preflight = result.get("plan", {}).get("preflight", {}) if isinstance(result.get("plan"), dict) else {}
|
|
115
115
|
if isinstance(preflight, dict) and preflight.get("status") in {"READY", "NOT_READY"}:
|
|
116
116
|
status = preflight["status"]
|
|
117
|
+
from .diagnostics import envelope_fields
|
|
117
118
|
return {
|
|
119
|
+
**envelope_fields(operation=command, status=status, operation_id=operation_id),
|
|
118
120
|
"operationId": operation_id,
|
|
119
121
|
"command": command,
|
|
120
122
|
"status": status,
|
|
@@ -207,6 +209,11 @@ def _export_core_args(args: argparse.Namespace, home: Path) -> list[str]:
|
|
|
207
209
|
if args.include_installed_bundles:
|
|
208
210
|
argv.append("--include-installed-bundles")
|
|
209
211
|
_append_repeated(argv, "--required-secret", args.required_secret)
|
|
212
|
+
if args.preset != "custom":
|
|
213
|
+
_append_option(argv, "--preset", args.preset)
|
|
214
|
+
_append_option(argv, "--data-selection-json", args.data_selection_json)
|
|
215
|
+
if args.plan_only:
|
|
216
|
+
argv.append("--plan-only")
|
|
210
217
|
return argv
|
|
211
218
|
|
|
212
219
|
|
|
@@ -294,6 +301,10 @@ def _finish_operation(
|
|
|
294
301
|
|
|
295
302
|
|
|
296
303
|
def _run_export(args: argparse.Namespace) -> int:
|
|
304
|
+
if args.plan_only:
|
|
305
|
+
code, payload = _invoke_core(_export_core_args(args, _dsh_home(args.dsh_home)))
|
|
306
|
+
_print(payload, as_json=args.json)
|
|
307
|
+
return code
|
|
297
308
|
home = _operation_home(args, profile=args.profile)
|
|
298
309
|
exit_code, result = _invoke_core(_export_core_args(args, home))
|
|
299
310
|
return _finish_operation("export", args, home, exit_code, result)
|
|
@@ -399,6 +410,9 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
399
410
|
export.add_argument("--embed", action="append", default=[], metavar="PLUGIN")
|
|
400
411
|
export.add_argument("--reference-only", action="append", default=[], metavar="PLUGIN")
|
|
401
412
|
export.add_argument("--include-installed-bundles", action="store_true", help="include direct installed Bundle inventory in the exported Profile composition")
|
|
413
|
+
export.add_argument("--preset", choices=("share", "freeze", "custom"), default="custom")
|
|
414
|
+
export.add_argument("--data-selection-json", help="JSON array of explicitly selected data paths, kinds and owners")
|
|
415
|
+
export.add_argument("--plan-only", action="store_true", help="preview export contents without writing a Crate")
|
|
402
416
|
export.add_argument("--required-secret", action="append", default=[], metavar="NAME")
|
|
403
417
|
export.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
404
418
|
export.add_argument("--dsh-home", help="DSH_HOME used for Profile-name resolution and reports")
|
|
@@ -445,6 +459,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
445
459
|
delete.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
446
460
|
delete.add_argument("--dsh-home", help="DSH_HOME containing the Profile and operation reports")
|
|
447
461
|
delete.set_defaults(handler=_run_delete_profile)
|
|
462
|
+
from .troubleshooting import configure_parser
|
|
463
|
+
configure_parser(subparsers)
|
|
448
464
|
return parser
|
|
449
465
|
|
|
450
466
|
|
|
@@ -126,16 +126,23 @@ def validate_manifest(value: Any, *, require_integrity: bool = False) -> dict[st
|
|
|
126
126
|
manifest = _object(value, "manifest")
|
|
127
127
|
# Report an unsupported schema before checking version-specific required
|
|
128
128
|
# fields, so a future schema cannot be misdiagnosed as a malformed v1 Pack.
|
|
129
|
-
if "schemaVersion" in manifest and manifest["schemaVersion"]
|
|
129
|
+
if "schemaVersion" in manifest and (type(manifest["schemaVersion"]) is not int or manifest["schemaVersion"] not in {1, 2, 3}):
|
|
130
130
|
raise SchemaValidationError(
|
|
131
131
|
f"unsupported manifest.schemaVersion: {manifest['schemaVersion']!r}; expected {SCHEMA_VERSION}"
|
|
132
132
|
)
|
|
133
133
|
allowed = {"schemaVersion", "format", "packVersion", "profile", "environment", "requiredSecrets", "integrity"}
|
|
134
|
+
if manifest.get("schemaVersion") in {2, 3}:
|
|
135
|
+
_validate_completeness(manifest.get("completeness"))
|
|
136
|
+
if not isinstance(manifest.get("dataInventory"), list):
|
|
137
|
+
raise SchemaValidationError("schema 2 requires dataInventory")
|
|
138
|
+
allowed.update({"exportMode", "dataInventory", "completeness"})
|
|
139
|
+
if not isinstance(manifest.get("exportMode"), str) or manifest["exportMode"] not in {"share", "freeze", "custom"}:
|
|
140
|
+
raise SchemaValidationError("schema 2 requires exportMode")
|
|
134
141
|
required = {"schemaVersion", "format", "packVersion", "profile", "environment"}
|
|
135
142
|
if require_integrity:
|
|
136
143
|
required.add("integrity")
|
|
137
144
|
_strict_keys(manifest, allowed, required, "manifest")
|
|
138
|
-
if manifest["schemaVersion"]
|
|
145
|
+
if manifest["schemaVersion"] not in {1, 2, 3}:
|
|
139
146
|
raise SchemaValidationError(
|
|
140
147
|
f"unsupported manifest.schemaVersion: {manifest['schemaVersion']!r}; expected {SCHEMA_VERSION}"
|
|
141
148
|
)
|
|
@@ -162,6 +169,30 @@ def validate_manifest(value: Any, *, require_integrity: bool = False) -> dict[st
|
|
|
162
169
|
return manifest
|
|
163
170
|
|
|
164
171
|
|
|
172
|
+
|
|
173
|
+
def _validate_completeness(value: Any) -> None:
|
|
174
|
+
data = _object(value, "manifest.completeness")
|
|
175
|
+
arrays = {"embedded", "referenceOnly", "secretNameOnly", "excluded", "unsupported"}
|
|
176
|
+
keys = arrays | {"hostDependencies", "offlineRestorable", "verification", "reason"}
|
|
177
|
+
_strict_keys(data, keys, keys, "manifest.completeness")
|
|
178
|
+
for key in arrays:
|
|
179
|
+
if not isinstance(data[key], list) or any(not isinstance(x, str) or not x for x in data[key]):
|
|
180
|
+
raise SchemaValidationError(f"completeness.{key} must be an array of names")
|
|
181
|
+
if not isinstance(data["hostDependencies"], list):
|
|
182
|
+
raise SchemaValidationError("completeness.hostDependencies must be an array")
|
|
183
|
+
for item in data["hostDependencies"]:
|
|
184
|
+
host = _object(item, "host dependency")
|
|
185
|
+
_strict_keys(host, {"name", "version", "requiredBy"}, {"name", "version"}, "host dependency")
|
|
186
|
+
for key in host:
|
|
187
|
+
_string(host[key], "host dependency." + key)
|
|
188
|
+
# Export declarations are not runtime evidence. YES belongs in a separately
|
|
189
|
+
# executed verification report, never in this format's export claim.
|
|
190
|
+
expected = "NO" if data["referenceOnly"] else "UNPROVEN"
|
|
191
|
+
if data["offlineRestorable"] != expected or data["verification"] != "UNTESTED":
|
|
192
|
+
raise SchemaValidationError("export completeness cannot claim verified restoration")
|
|
193
|
+
_string(data["reason"], "completeness.reason")
|
|
194
|
+
|
|
195
|
+
|
|
165
196
|
def validate_environment(value: Any) -> dict[str, Any]:
|
|
166
197
|
environment = _object(value, "manifest.environment")
|
|
167
198
|
_strict_keys(environment, {"schemaVersion", "os", "node", "dsh"}, {"schemaVersion", "os", "node", "dsh"}, "manifest.environment")
|
|
@@ -201,10 +232,37 @@ def _validate_package_name(value: Any, path: str) -> str:
|
|
|
201
232
|
return name
|
|
202
233
|
|
|
203
234
|
|
|
235
|
+
def bundle_patch_paths(value: Any) -> tuple[str, ...]:
|
|
236
|
+
"""Return safe patch paths in declared composition order, accepting legacy strings."""
|
|
237
|
+
values = [value] if isinstance(value, str) else value
|
|
238
|
+
if not isinstance(values, list) or not values:
|
|
239
|
+
raise SchemaValidationError("bundle.patch must be a string or a non-empty ordered array of strings")
|
|
240
|
+
paths: list[str] = []
|
|
241
|
+
for item in values:
|
|
242
|
+
path = _string(item, "bundle.patch item").replace("\\", "/")
|
|
243
|
+
if path.startswith("./"):
|
|
244
|
+
path = path[2:]
|
|
245
|
+
if ":" in path:
|
|
246
|
+
raise SchemaValidationError("bundle.patch path cannot contain a drive or alternate stream")
|
|
247
|
+
validate_archive_path(f"package/{path}", expected_root="package")
|
|
248
|
+
reserved = {"con", "prn", "aux", "nul", *(f"com{i}" for i in range(1, 10)), *(f"lpt{i}" for i in range(1, 10))}
|
|
249
|
+
if any(part.rstrip(" .") != part or part.split(".", 1)[0].casefold() in reserved for part in path.split("/")):
|
|
250
|
+
raise SchemaValidationError("bundle.patch path contains a non-portable Windows filename")
|
|
251
|
+
if path.casefold() in {prior.casefold() for prior in paths}:
|
|
252
|
+
raise SchemaValidationError("bundle.patch contains duplicate paths")
|
|
253
|
+
paths.append(path)
|
|
254
|
+
return tuple(paths)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def validate_format_pair(manifest: Mapping[str, Any], lock: Mapping[str, Any]) -> None:
|
|
258
|
+
if lock.get("schemaVersion") == 2 and manifest.get("schemaVersion") != 3:
|
|
259
|
+
raise SchemaValidationError("ordered Bundle patch arrays require manifest schema 3 and plugins.lock schema 2")
|
|
260
|
+
|
|
261
|
+
|
|
204
262
|
def validate_plugins_lock(value: Any, *, artifact_paths: set[str] | None = None) -> dict[str, Any]:
|
|
205
263
|
lock = _object(value, "plugins.lock")
|
|
206
264
|
_strict_keys(lock, {"schemaVersion", "plugins"}, {"schemaVersion", "plugins"}, "plugins.lock")
|
|
207
|
-
if lock["schemaVersion"]
|
|
265
|
+
if type(lock["schemaVersion"]) is not int or lock["schemaVersion"] not in {1, 2}:
|
|
208
266
|
raise SchemaValidationError(
|
|
209
267
|
f"unsupported plugins.lock.schemaVersion: {lock['schemaVersion']!r}; expected {SCHEMA_VERSION}"
|
|
210
268
|
)
|
|
@@ -250,7 +308,9 @@ def validate_plugins_lock(value: Any, *, artifact_paths: set[str] | None = None)
|
|
|
250
308
|
bundle_enabled = _boolean(bundle["enabled"], f"{path}.bundle.enabled")
|
|
251
309
|
if bundle_enabled:
|
|
252
310
|
_integer(bundle["order"], f"{path}.bundle.order", minimum=0)
|
|
253
|
-
|
|
311
|
+
if isinstance(bundle["patch"], list) and lock["schemaVersion"] != 2:
|
|
312
|
+
raise SchemaValidationError("Bundle patch arrays require plugins.lock schema 2")
|
|
313
|
+
bundle_patch_paths(bundle["patch"])
|
|
254
314
|
else:
|
|
255
315
|
if bundle["order"] is not None or bundle["patch"] is not None:
|
|
256
316
|
raise SchemaValidationError(f"{path}.bundle.order and patch must be null when bundle.enabled is false")
|