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,811 @@
|
|
|
1
|
+
"""Read-only Phase 3 Pack preflight checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tarfile
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from io import BytesIO
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
from .errors import DshPackError, IntegrityError, SchemaValidationError, _default_checks
|
|
14
|
+
from .network import network_source_details
|
|
15
|
+
from .pack import _read_entries, sha256_bytes
|
|
16
|
+
from .validation import validate_manifest, validate_plugins_lock
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_SEVERITIES = ("BLOCKER", "WARNING", "INFO")
|
|
20
|
+
_STAGE_BY_CODE = {
|
|
21
|
+
"FORMAT_ERROR": "format",
|
|
22
|
+
"SCHEMA_ERROR": "schema",
|
|
23
|
+
"HASH_OR_INTEGRITY_ERROR": "integrity",
|
|
24
|
+
"HASH_MISMATCH": "integrity",
|
|
25
|
+
"INTEGRITY_FILE_MISSING": "integrity",
|
|
26
|
+
"INTEGRITY_FILE_SET_MISMATCH": "integrity",
|
|
27
|
+
"VERSION_MISSING": "identity",
|
|
28
|
+
"PACKAGE_IDENTITY_DRIFT": "identity",
|
|
29
|
+
"REQUIRED_ARTIFACT_MISSING": "artifact",
|
|
30
|
+
"OPTIONAL_ARTIFACT_MISSING": "artifact",
|
|
31
|
+
"PLUGIN_MISSING": "plugin",
|
|
32
|
+
"OPTIONAL_PLUGIN_MISSING": "plugin",
|
|
33
|
+
"PLUGIN_VERSION_MISMATCH": "plugin",
|
|
34
|
+
"NETWORK_INSTALL_REQUIRED": "plugin",
|
|
35
|
+
"ENVIRONMENT_EXACT_MATCH": "environment",
|
|
36
|
+
"ENVIRONMENT_VERSION_MISMATCH": "environment",
|
|
37
|
+
"ENVIRONMENT_OS_MISMATCH": "environment",
|
|
38
|
+
"SECRET_MISSING": "secrets",
|
|
39
|
+
"CONFLICT_BUNDLE_COMPOSITION": "composition",
|
|
40
|
+
"CONFLICT_DUPLICATE_BUNDLE_ORDER": "composition",
|
|
41
|
+
"CONFLICT_DUPLICATE_ARTIFACT": "composition",
|
|
42
|
+
}
|
|
43
|
+
_EXPECTED_BY_CODE = {
|
|
44
|
+
"FORMAT_ERROR": "a readable DSH Pack archive",
|
|
45
|
+
"SCHEMA_ERROR": "a supported DSH Pack schema",
|
|
46
|
+
"HASH_OR_INTEGRITY_ERROR": "declared integrity matches Pack contents",
|
|
47
|
+
"HASH_MISMATCH": "declared SHA-256 matches artifact bytes",
|
|
48
|
+
"INTEGRITY_FILE_MISSING": "all declared Pack files are present",
|
|
49
|
+
"INTEGRITY_FILE_SET_MISMATCH": "Pack files exactly match the integrity set",
|
|
50
|
+
"VERSION_MISSING": "each plugin has resolved.version",
|
|
51
|
+
"PACKAGE_IDENTITY_DRIFT": "artifact package identity matches lock identity",
|
|
52
|
+
"REQUIRED_ARTIFACT_MISSING": "required embedded artifact is present",
|
|
53
|
+
"OPTIONAL_ARTIFACT_MISSING": "optional embedded artifact is present",
|
|
54
|
+
"PLUGIN_MISSING": "required reference-only plugin is available",
|
|
55
|
+
"OPTIONAL_PLUGIN_MISSING": "optional reference-only plugin is available",
|
|
56
|
+
"PLUGIN_VERSION_MISMATCH": "installed reference-only version matches Pack",
|
|
57
|
+
"NETWORK_INSTALL_REQUIRED": "a supported recorded network source is available for npm installation",
|
|
58
|
+
"ENVIRONMENT_EXACT_MATCH": "current environment satisfies Pack requirement",
|
|
59
|
+
"ENVIRONMENT_VERSION_MISMATCH": "current version satisfies Pack requirement",
|
|
60
|
+
"ENVIRONMENT_OS_MISMATCH": "current OS matches Pack requirement",
|
|
61
|
+
"SECRET_MISSING": "required Secret name is available",
|
|
62
|
+
"CONFLICT_BUNDLE_COMPOSITION": "Profile and lock Bundle composition agree",
|
|
63
|
+
"CONFLICT_DUPLICATE_BUNDLE_ORDER": "each enabled Bundle has a unique order",
|
|
64
|
+
"CONFLICT_DUPLICATE_ARTIFACT": "each artifact path is owned by one plugin",
|
|
65
|
+
}
|
|
66
|
+
_NEXT_STEP_BY_CODE = {
|
|
67
|
+
"FORMAT_ERROR": "Provide a readable .dshcrate and run inspect again.",
|
|
68
|
+
"SCHEMA_ERROR": "Regenerate the Pack with the supported schema.",
|
|
69
|
+
"HASH_OR_INTEGRITY_ERROR": "Regenerate or restore the Pack from an untampered source.",
|
|
70
|
+
"HASH_MISMATCH": "Restore the original artifact or regenerate the Pack.",
|
|
71
|
+
"INTEGRITY_FILE_MISSING": "Restore the missing Pack member and regenerate its integrity table.",
|
|
72
|
+
"INTEGRITY_FILE_SET_MISMATCH": "Regenerate the Pack so its integrity file set matches its contents.",
|
|
73
|
+
"VERSION_MISSING": "Regenerate plugins.lock.json with a resolved plugin version.",
|
|
74
|
+
"PACKAGE_IDENTITY_DRIFT": "Restore the artifact matching plugins.lock.json or re-export the Profile.",
|
|
75
|
+
"REQUIRED_ARTIFACT_MISSING": "Restore the required embedded artifact before continuing.",
|
|
76
|
+
"OPTIONAL_ARTIFACT_MISSING": "Restore the optional artifact or intentionally export the plugin reference-only.",
|
|
77
|
+
"PLUGIN_MISSING": "Install the required reference-only plugin at the Pack version.",
|
|
78
|
+
"OPTIONAL_PLUGIN_MISSING": "Install the optional plugin or accept the degraded capability.",
|
|
79
|
+
"PLUGIN_VERSION_MISMATCH": "Install the exact reference-only plugin version from the Pack.",
|
|
80
|
+
"NETWORK_INSTALL_REQUIRED": "Import will download this plugin with npm into the temporary Profile before commit.",
|
|
81
|
+
"ENVIRONMENT_EXACT_MATCH": "Continue to the next preflight check.",
|
|
82
|
+
"ENVIRONMENT_VERSION_MISMATCH": "Use a compatible DSH/Node version before continuing.",
|
|
83
|
+
"ENVIRONMENT_OS_MISMATCH": "Review the OS difference before importing the Pack.",
|
|
84
|
+
"SECRET_MISSING": "Provide the Secret outside the Pack before running capability tests.",
|
|
85
|
+
"CONFLICT_BUNDLE_COMPOSITION": "Restore the Profile Bundle list and lock order before continuing.",
|
|
86
|
+
"CONFLICT_DUPLICATE_BUNDLE_ORDER": "Assign each enabled Bundle plugin a unique order and regenerate the Pack.",
|
|
87
|
+
"CONFLICT_DUPLICATE_ARTIFACT": "Give each embedded artifact a unique path and regenerate the Pack.",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Finding:
|
|
93
|
+
severity: str
|
|
94
|
+
code: str
|
|
95
|
+
message: str
|
|
96
|
+
stage: str
|
|
97
|
+
item: str
|
|
98
|
+
expected: Any
|
|
99
|
+
observed: Any
|
|
100
|
+
evidence: Any
|
|
101
|
+
impact: str
|
|
102
|
+
can_continue: bool
|
|
103
|
+
suggested_next_step: str
|
|
104
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
def __post_init__(self) -> None:
|
|
107
|
+
if self.severity not in _SEVERITIES:
|
|
108
|
+
raise ValueError(f"unsupported preflight severity: {self.severity}")
|
|
109
|
+
|
|
110
|
+
def as_dict(self) -> dict[str, Any]:
|
|
111
|
+
return {
|
|
112
|
+
"severity": self.severity,
|
|
113
|
+
"code": self.code,
|
|
114
|
+
"message": self.message,
|
|
115
|
+
"stage": self.stage,
|
|
116
|
+
"item": self.item,
|
|
117
|
+
"expected": self.expected,
|
|
118
|
+
"observed": self.observed,
|
|
119
|
+
"evidence": self.evidence,
|
|
120
|
+
"impact": self.impact,
|
|
121
|
+
"canContinue": self.can_continue,
|
|
122
|
+
"suggestedNextStep": self.suggested_next_step,
|
|
123
|
+
"suggestedChecks": _default_checks(self.stage, self.code),
|
|
124
|
+
"details": self.details,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class PreflightContext:
|
|
130
|
+
"""Optional facts about the machine that will receive the Pack.
|
|
131
|
+
|
|
132
|
+
``available_plugins=None`` means that reference-only plugin availability is
|
|
133
|
+
not known. An explicit mapping is treated as the complete installed-plugin
|
|
134
|
+
inventory, allowing missing optional plugins to be reported honestly.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
current_environment: Mapping[str, Any] = field(default_factory=dict)
|
|
138
|
+
available_secrets: set[str] | None = None
|
|
139
|
+
available_plugins: Mapping[str, str | None] | None = None
|
|
140
|
+
allow_network_reference_install: bool = False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class PreflightResult:
|
|
145
|
+
pack_path: str
|
|
146
|
+
status: str
|
|
147
|
+
pack: dict[str, Any]
|
|
148
|
+
schema: dict[str, Any]
|
|
149
|
+
environment: dict[str, Any]
|
|
150
|
+
plugins: list[dict[str, Any]]
|
|
151
|
+
secrets: dict[str, Any]
|
|
152
|
+
artifacts: list[dict[str, Any]]
|
|
153
|
+
conflicts: list[dict[str, Any]]
|
|
154
|
+
findings: list[Finding]
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def blockers(self) -> list[Finding]:
|
|
158
|
+
return [finding for finding in self.findings if finding.severity == "BLOCKER"]
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def warnings(self) -> list[Finding]:
|
|
162
|
+
return [finding for finding in self.findings if finding.severity == "WARNING"]
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def warning_count(self) -> int:
|
|
166
|
+
return len(self.warnings)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def required_plugins(self) -> list[dict[str, Any]]:
|
|
170
|
+
return [plugin for plugin in self.plugins if plugin.get("required") is True]
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def optional_plugins(self) -> list[dict[str, Any]]:
|
|
174
|
+
return [plugin for plugin in self.plugins if plugin.get("required") is not True]
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def can_continue(self) -> bool:
|
|
178
|
+
"""Core-owned decision for actions that require a ready Preflight."""
|
|
179
|
+
|
|
180
|
+
return self.status == "READY"
|
|
181
|
+
|
|
182
|
+
def as_dict(self) -> dict[str, Any]:
|
|
183
|
+
return {
|
|
184
|
+
"pack": self.pack,
|
|
185
|
+
"schema": self.schema,
|
|
186
|
+
"environment": self.environment,
|
|
187
|
+
"plugins": self.plugins,
|
|
188
|
+
"secrets": self.secrets,
|
|
189
|
+
"artifacts": self.artifacts,
|
|
190
|
+
"conflicts": self.conflicts,
|
|
191
|
+
"findings": [finding.as_dict() for finding in self.findings],
|
|
192
|
+
"warningCount": self.warning_count,
|
|
193
|
+
"requiredPlugins": self.required_plugins,
|
|
194
|
+
"optionalPlugins": self.optional_plugins,
|
|
195
|
+
"status": self.status,
|
|
196
|
+
"canContinue": self.can_continue,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _json_object(content: bytes | None, name: str) -> dict[str, Any] | None:
|
|
201
|
+
if content is None:
|
|
202
|
+
return None
|
|
203
|
+
try:
|
|
204
|
+
value = json.loads(content.decode("utf-8"))
|
|
205
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
206
|
+
return None
|
|
207
|
+
return value if isinstance(value, dict) else None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _finding(
|
|
211
|
+
severity: str,
|
|
212
|
+
code: str,
|
|
213
|
+
message: str,
|
|
214
|
+
*,
|
|
215
|
+
stage: str | None = None,
|
|
216
|
+
item: str | None = None,
|
|
217
|
+
expected: Any = None,
|
|
218
|
+
observed: Any = None,
|
|
219
|
+
evidence: Any = None,
|
|
220
|
+
impact: str | None = None,
|
|
221
|
+
can_continue: bool | None = None,
|
|
222
|
+
suggested_next_step: str | None = None,
|
|
223
|
+
**details: Any,
|
|
224
|
+
) -> Finding:
|
|
225
|
+
if observed is None:
|
|
226
|
+
observed = details.get("actual", message)
|
|
227
|
+
if evidence is None:
|
|
228
|
+
evidence = dict(details) or {"message": message}
|
|
229
|
+
if impact is None:
|
|
230
|
+
impact = "No blocking issue detected." if severity == "INFO" else (
|
|
231
|
+
"Preflight can continue, but the Pack has a non-blocking issue."
|
|
232
|
+
if severity == "WARNING"
|
|
233
|
+
else "Preflight cannot continue safely."
|
|
234
|
+
)
|
|
235
|
+
if can_continue is None:
|
|
236
|
+
can_continue = severity != "BLOCKER"
|
|
237
|
+
return Finding(
|
|
238
|
+
severity=severity,
|
|
239
|
+
code=code,
|
|
240
|
+
message=message,
|
|
241
|
+
stage=stage or _STAGE_BY_CODE.get(code, "preflight"),
|
|
242
|
+
item=item or str(details.get("path") or details.get("plugin") or details.get("secret") or code),
|
|
243
|
+
expected=expected if expected is not None else _EXPECTED_BY_CODE.get(code),
|
|
244
|
+
observed=observed,
|
|
245
|
+
evidence=evidence,
|
|
246
|
+
impact=impact,
|
|
247
|
+
can_continue=can_continue,
|
|
248
|
+
suggested_next_step=suggested_next_step or _NEXT_STEP_BY_CODE.get(code, "Review this finding and run inspect again."),
|
|
249
|
+
details=details,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _error_finding(error: DshPackError) -> Finding:
|
|
254
|
+
if isinstance(error, IntegrityError):
|
|
255
|
+
return _finding("BLOCKER", "HASH_OR_INTEGRITY_ERROR", str(error))
|
|
256
|
+
if isinstance(error, SchemaValidationError):
|
|
257
|
+
return _finding("BLOCKER", "SCHEMA_ERROR", str(error))
|
|
258
|
+
return _finding("BLOCKER", "FORMAT_ERROR", str(error))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _environment_value(environment: Mapping[str, Any], section: str, key: str = "version") -> str | None:
|
|
262
|
+
value = environment.get(section)
|
|
263
|
+
if not isinstance(value, Mapping):
|
|
264
|
+
return None
|
|
265
|
+
result = value.get(key)
|
|
266
|
+
return result if isinstance(result, str) and result else None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _version_matches(requirement: str, current: str) -> bool:
|
|
270
|
+
requirement = requirement.strip().lower()
|
|
271
|
+
current = current.strip().lower()
|
|
272
|
+
# Node reports version strings both with and without a leading "v"
|
|
273
|
+
# (e.g. process.version is "v24.13.0" while process.versions.node is
|
|
274
|
+
# "24.13.0"). A formatting difference must never be treated as a
|
|
275
|
+
# compatibility difference.
|
|
276
|
+
if requirement.startswith("v"):
|
|
277
|
+
requirement = requirement[1:]
|
|
278
|
+
if current.startswith("v"):
|
|
279
|
+
current = current[1:]
|
|
280
|
+
if requirement == current:
|
|
281
|
+
return True
|
|
282
|
+
if requirement.endswith(".x"):
|
|
283
|
+
return current.startswith(requirement[:-1])
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _profile_bundle_names(entries: Mapping[str, bytes]) -> list[str] | None:
|
|
288
|
+
profile_manifest = _json_object(entries.get("profile/package.json"), "profile/package.json")
|
|
289
|
+
if profile_manifest is None:
|
|
290
|
+
return None
|
|
291
|
+
dsh = profile_manifest.get("dsh")
|
|
292
|
+
if not isinstance(dsh, Mapping):
|
|
293
|
+
return []
|
|
294
|
+
profile = dsh.get("profile")
|
|
295
|
+
if not isinstance(profile, Mapping):
|
|
296
|
+
return []
|
|
297
|
+
bundles = profile.get("bundles")
|
|
298
|
+
if not isinstance(bundles, list) or any(not isinstance(name, str) for name in bundles):
|
|
299
|
+
return None
|
|
300
|
+
return list(bundles)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _artifact_package_identity(content: bytes) -> dict[str, str] | None:
|
|
304
|
+
"""Read package identity from an npm tarball without extracting to disk."""
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
with tarfile.open(fileobj=BytesIO(content), mode="r:gz") as archive:
|
|
308
|
+
try:
|
|
309
|
+
member = archive.getmember("package/package.json")
|
|
310
|
+
except KeyError:
|
|
311
|
+
return None
|
|
312
|
+
if not member.isfile():
|
|
313
|
+
return None
|
|
314
|
+
extracted = archive.extractfile(member)
|
|
315
|
+
if extracted is None:
|
|
316
|
+
return None
|
|
317
|
+
value = json.loads(extracted.read().decode("utf-8"))
|
|
318
|
+
except (OSError, tarfile.TarError, UnicodeDecodeError, json.JSONDecodeError):
|
|
319
|
+
return None
|
|
320
|
+
if not isinstance(value, Mapping):
|
|
321
|
+
return None
|
|
322
|
+
name = value.get("name")
|
|
323
|
+
version = value.get("version")
|
|
324
|
+
if not isinstance(name, str) or not isinstance(version, str):
|
|
325
|
+
return None
|
|
326
|
+
return {"name": name, "version": version}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _status(findings: list[Finding]) -> str:
|
|
330
|
+
if any(finding.severity == "BLOCKER" for finding in findings):
|
|
331
|
+
return "NOT_READY"
|
|
332
|
+
return "READY"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _empty_result(source: Path, findings: list[Finding]) -> PreflightResult:
|
|
336
|
+
return PreflightResult(
|
|
337
|
+
pack_path=str(source),
|
|
338
|
+
status=_status(findings),
|
|
339
|
+
pack={"path": str(source)},
|
|
340
|
+
schema={"status": "invalid"},
|
|
341
|
+
environment={"required": {}, "current": {}},
|
|
342
|
+
plugins=[],
|
|
343
|
+
secrets={"required": [], "missing": [], "availableNames": []},
|
|
344
|
+
artifacts=[],
|
|
345
|
+
conflicts=[],
|
|
346
|
+
findings=findings,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def inspect_pack(
|
|
351
|
+
source: str | os.PathLike[str],
|
|
352
|
+
*,
|
|
353
|
+
context: PreflightContext | None = None,
|
|
354
|
+
) -> PreflightResult:
|
|
355
|
+
"""Inspect one Pack without installing, extracting, or modifying anything."""
|
|
356
|
+
|
|
357
|
+
source_path = Path(source).resolve()
|
|
358
|
+
context = context or PreflightContext()
|
|
359
|
+
findings: list[Finding] = []
|
|
360
|
+
try:
|
|
361
|
+
entries = _read_entries(source_path)
|
|
362
|
+
except DshPackError as error:
|
|
363
|
+
return _empty_result(source_path, [_error_finding(error)])
|
|
364
|
+
|
|
365
|
+
if "manifest.json" not in entries:
|
|
366
|
+
findings.append(_finding("BLOCKER", "FORMAT_ERROR", "Pack is missing manifest.json"))
|
|
367
|
+
if "plugins.lock.json" not in entries:
|
|
368
|
+
findings.append(_finding("BLOCKER", "FORMAT_ERROR", "Pack is missing plugins.lock.json"))
|
|
369
|
+
if not any(name.startswith("profile/") for name in entries):
|
|
370
|
+
findings.append(_finding("BLOCKER", "FORMAT_ERROR", "Pack is missing profile/ files"))
|
|
371
|
+
if findings:
|
|
372
|
+
return _empty_result(source_path, findings)
|
|
373
|
+
|
|
374
|
+
manifest = _json_object(entries["manifest.json"], "manifest.json")
|
|
375
|
+
lock = _json_object(entries["plugins.lock.json"], "plugins.lock.json")
|
|
376
|
+
if manifest is None:
|
|
377
|
+
findings.append(_finding("BLOCKER", "SCHEMA_ERROR", "manifest.json must contain a JSON object"))
|
|
378
|
+
if lock is None:
|
|
379
|
+
findings.append(_finding("BLOCKER", "SCHEMA_ERROR", "plugins.lock.json must contain a JSON object"))
|
|
380
|
+
if findings:
|
|
381
|
+
return _empty_result(source_path, findings)
|
|
382
|
+
assert manifest is not None
|
|
383
|
+
assert lock is not None
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
validate_manifest(manifest, require_integrity=True)
|
|
387
|
+
except SchemaValidationError as error:
|
|
388
|
+
findings.append(_finding("BLOCKER", "SCHEMA_ERROR", str(error)))
|
|
389
|
+
|
|
390
|
+
raw_plugins = lock.get("plugins", [])
|
|
391
|
+
if not isinstance(raw_plugins, list):
|
|
392
|
+
raw_plugins = []
|
|
393
|
+
plugin_names: set[str] = set()
|
|
394
|
+
missing_version_indexes: set[int] = set()
|
|
395
|
+
for index, raw_plugin in enumerate(raw_plugins):
|
|
396
|
+
if not isinstance(raw_plugin, Mapping):
|
|
397
|
+
continue
|
|
398
|
+
name = raw_plugin.get("name")
|
|
399
|
+
if isinstance(name, str):
|
|
400
|
+
if name in plugin_names:
|
|
401
|
+
findings.append(_finding("BLOCKER", "SCHEMA_ERROR", f"duplicate plugin name: {name}"))
|
|
402
|
+
plugin_names.add(name)
|
|
403
|
+
resolved = raw_plugin.get("resolved")
|
|
404
|
+
version = resolved.get("version") if isinstance(resolved, Mapping) else None
|
|
405
|
+
if not isinstance(version, str) or not version.strip():
|
|
406
|
+
missing_version_indexes.add(index)
|
|
407
|
+
findings.append(
|
|
408
|
+
_finding(
|
|
409
|
+
"BLOCKER",
|
|
410
|
+
"VERSION_MISSING",
|
|
411
|
+
f"plugin at plugins.lock.plugins[{index}] is missing resolved.version",
|
|
412
|
+
index=index,
|
|
413
|
+
plugin=name,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
artifact_entries = {name for name in entries if name.startswith("plugins/")}
|
|
418
|
+
artifacts: list[dict[str, Any]] = []
|
|
419
|
+
artifact_paths: dict[str, str] = {}
|
|
420
|
+
for index, raw_plugin in enumerate(raw_plugins):
|
|
421
|
+
if not isinstance(raw_plugin, Mapping):
|
|
422
|
+
continue
|
|
423
|
+
name = raw_plugin.get("name", f"plugin[{index}]")
|
|
424
|
+
required = raw_plugin.get("required") is True
|
|
425
|
+
artifact = raw_plugin.get("artifact")
|
|
426
|
+
if not isinstance(artifact, Mapping):
|
|
427
|
+
continue
|
|
428
|
+
mode = artifact.get("mode")
|
|
429
|
+
path = artifact.get("path") if isinstance(artifact.get("path"), str) else None
|
|
430
|
+
artifact_status = "reference-only"
|
|
431
|
+
if mode == "embedded":
|
|
432
|
+
artifact_status = "present" if path in artifact_entries else "missing"
|
|
433
|
+
if path and path in artifact_paths:
|
|
434
|
+
findings.append(
|
|
435
|
+
_finding(
|
|
436
|
+
"BLOCKER",
|
|
437
|
+
"CONFLICT_DUPLICATE_ARTIFACT",
|
|
438
|
+
f"plugins {artifact_paths[path]} and {name} use the same artifact path: {path}",
|
|
439
|
+
path=path,
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
elif path:
|
|
443
|
+
artifact_paths[path] = str(name)
|
|
444
|
+
if path not in artifact_entries:
|
|
445
|
+
severity = "BLOCKER" if required else "WARNING"
|
|
446
|
+
code = "REQUIRED_ARTIFACT_MISSING" if required else "OPTIONAL_ARTIFACT_MISSING"
|
|
447
|
+
findings.append(
|
|
448
|
+
_finding(
|
|
449
|
+
severity,
|
|
450
|
+
code,
|
|
451
|
+
f"{name} embedded artifact is missing: {path}",
|
|
452
|
+
plugin=name,
|
|
453
|
+
path=path,
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
artifacts.append(
|
|
457
|
+
{
|
|
458
|
+
"plugin": name,
|
|
459
|
+
"required": required,
|
|
460
|
+
"mode": mode,
|
|
461
|
+
"path": path,
|
|
462
|
+
"status": artifact_status,
|
|
463
|
+
"sha256": artifact.get("sha256"),
|
|
464
|
+
}
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
validate_plugins_lock(lock, artifact_paths=artifact_entries)
|
|
469
|
+
except SchemaValidationError as error:
|
|
470
|
+
message = str(error)
|
|
471
|
+
if "resolved.version" not in message and "resolved is missing fields" not in message:
|
|
472
|
+
if "artifact.path is missing from plugins/" not in message:
|
|
473
|
+
findings.append(_finding("BLOCKER", "SCHEMA_ERROR", message))
|
|
474
|
+
|
|
475
|
+
hash_mismatch_paths: set[str] = set()
|
|
476
|
+
integrity = manifest.get("integrity")
|
|
477
|
+
if isinstance(integrity, Mapping) and isinstance(integrity.get("files"), Mapping):
|
|
478
|
+
declared = dict(integrity["files"])
|
|
479
|
+
actual = set(entries) - {"manifest.json"}
|
|
480
|
+
missing = set(declared) - actual
|
|
481
|
+
unexpected = actual - set(declared)
|
|
482
|
+
plugin_artifact_paths = set(artifact_paths)
|
|
483
|
+
for name in sorted(missing - plugin_artifact_paths):
|
|
484
|
+
findings.append(_finding("BLOCKER", "INTEGRITY_FILE_MISSING", f"integrity file is missing: {name}", path=name))
|
|
485
|
+
if unexpected:
|
|
486
|
+
findings.append(
|
|
487
|
+
_finding(
|
|
488
|
+
"BLOCKER",
|
|
489
|
+
"INTEGRITY_FILE_SET_MISMATCH",
|
|
490
|
+
f"integrity has unexpected files: {sorted(unexpected)}",
|
|
491
|
+
files=sorted(unexpected),
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
for name, expected in declared.items():
|
|
495
|
+
if name not in entries:
|
|
496
|
+
continue
|
|
497
|
+
actual_hash = sha256_bytes(entries[name])
|
|
498
|
+
if actual_hash != expected:
|
|
499
|
+
hash_mismatch_paths.add(name)
|
|
500
|
+
plugin = artifact_paths.get(name)
|
|
501
|
+
findings.append(
|
|
502
|
+
_finding(
|
|
503
|
+
"BLOCKER",
|
|
504
|
+
"HASH_MISMATCH",
|
|
505
|
+
f"{name} hash mismatch",
|
|
506
|
+
path=name,
|
|
507
|
+
expected=expected,
|
|
508
|
+
actual=actual_hash,
|
|
509
|
+
plugin=plugin,
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
for artifact in artifacts:
|
|
513
|
+
if artifact.get("mode") != "embedded" or artifact.get("path") not in entries:
|
|
514
|
+
continue
|
|
515
|
+
expected = artifact.get("sha256")
|
|
516
|
+
path = artifact.get("path")
|
|
517
|
+
actual_hash = sha256_bytes(entries[path])
|
|
518
|
+
if isinstance(expected, str) and actual_hash != expected:
|
|
519
|
+
if path not in hash_mismatch_paths:
|
|
520
|
+
findings.append(
|
|
521
|
+
_finding(
|
|
522
|
+
"BLOCKER",
|
|
523
|
+
"HASH_MISMATCH",
|
|
524
|
+
f"{path} hash mismatch in plugins.lock.json",
|
|
525
|
+
path=path,
|
|
526
|
+
expected=expected,
|
|
527
|
+
actual=actual_hash,
|
|
528
|
+
plugin=artifact.get("plugin"),
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
hash_mismatch_paths.add(path)
|
|
532
|
+
for artifact in artifacts:
|
|
533
|
+
if artifact.get("path") in hash_mismatch_paths:
|
|
534
|
+
artifact["status"] = "hash-mismatch"
|
|
535
|
+
|
|
536
|
+
bundle_orders: dict[int, str] = {}
|
|
537
|
+
bundle_names: list[str] = []
|
|
538
|
+
plugin_summaries: list[dict[str, Any]] = []
|
|
539
|
+
for index, raw_plugin in enumerate(raw_plugins):
|
|
540
|
+
if not isinstance(raw_plugin, Mapping):
|
|
541
|
+
continue
|
|
542
|
+
name = raw_plugin.get("name", f"plugin[{index}]")
|
|
543
|
+
bundle = raw_plugin.get("bundle")
|
|
544
|
+
if isinstance(bundle, Mapping) and bundle.get("enabled") is True:
|
|
545
|
+
order = bundle.get("order")
|
|
546
|
+
if isinstance(order, int) and not isinstance(order, bool):
|
|
547
|
+
if order in bundle_orders:
|
|
548
|
+
findings.append(
|
|
549
|
+
_finding(
|
|
550
|
+
"BLOCKER",
|
|
551
|
+
"CONFLICT_DUPLICATE_BUNDLE_ORDER",
|
|
552
|
+
f"plugins {bundle_orders[order]} and {name} share Bundle order {order}",
|
|
553
|
+
item="dsh.profile.bundles[]",
|
|
554
|
+
expected="unique Bundle order values",
|
|
555
|
+
observed={"order": order, "plugins": [bundle_orders[order], str(name)]},
|
|
556
|
+
evidence={"source": "plugins.lock.json", "order": order},
|
|
557
|
+
kind="duplicateBundleOrder",
|
|
558
|
+
order=order,
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
else:
|
|
562
|
+
bundle_orders[order] = str(name)
|
|
563
|
+
bundle_names.append(str(name))
|
|
564
|
+
plugin_summaries.append(
|
|
565
|
+
{
|
|
566
|
+
"name": name,
|
|
567
|
+
"required": raw_plugin.get("required"),
|
|
568
|
+
"requested": raw_plugin.get("requested"),
|
|
569
|
+
"resolved": raw_plugin.get("resolved"),
|
|
570
|
+
"runtime": raw_plugin.get("runtime"),
|
|
571
|
+
"bundle": bundle,
|
|
572
|
+
"artifact": raw_plugin.get("artifact"),
|
|
573
|
+
}
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
profile_bundles = _profile_bundle_names(entries)
|
|
577
|
+
if profile_bundles is not None and profile_bundles != bundle_names:
|
|
578
|
+
findings.append(
|
|
579
|
+
_finding(
|
|
580
|
+
"BLOCKER",
|
|
581
|
+
"CONFLICT_BUNDLE_COMPOSITION",
|
|
582
|
+
f"Profile bundle order {profile_bundles} differs from plugins.lock order {bundle_names}",
|
|
583
|
+
profile=profile_bundles,
|
|
584
|
+
lock=bundle_names,
|
|
585
|
+
)
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
current_environment = dict(context.current_environment)
|
|
589
|
+
required_environment = manifest.get("environment") if isinstance(manifest.get("environment"), Mapping) else {}
|
|
590
|
+
for section, label in (("dsh", "DSH"), ("node", "Node")):
|
|
591
|
+
required = _environment_value(required_environment, section)
|
|
592
|
+
current = _environment_value(current_environment, section)
|
|
593
|
+
if required and current:
|
|
594
|
+
if not _version_matches(required, current):
|
|
595
|
+
findings.append(
|
|
596
|
+
_finding(
|
|
597
|
+
"WARNING",
|
|
598
|
+
"ENVIRONMENT_VERSION_MISMATCH",
|
|
599
|
+
f"Pack was exported with {label} {required}; current {label} is {current} (runtime compatibility may differ)",
|
|
600
|
+
stage="environment",
|
|
601
|
+
item=section,
|
|
602
|
+
expected=required,
|
|
603
|
+
observed=current,
|
|
604
|
+
evidence={"required": required, "current": current},
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
required_os = _environment_value(required_environment, "os", "name")
|
|
608
|
+
current_os = _environment_value(current_environment, "os", "name")
|
|
609
|
+
required_os_version = _environment_value(required_environment, "os", "version")
|
|
610
|
+
current_os_version = _environment_value(current_environment, "os", "version")
|
|
611
|
+
os_name_mismatch = required_os and current_os and required_os.lower() != current_os.lower()
|
|
612
|
+
os_version_mismatch = (
|
|
613
|
+
required_os_version
|
|
614
|
+
and current_os_version
|
|
615
|
+
and required_os_version.lower() != current_os_version.lower()
|
|
616
|
+
)
|
|
617
|
+
if os_name_mismatch or os_version_mismatch:
|
|
618
|
+
findings.append(
|
|
619
|
+
_finding(
|
|
620
|
+
"WARNING",
|
|
621
|
+
"ENVIRONMENT_OS_MISMATCH",
|
|
622
|
+
f"Pack exported for {required_os} {required_os_version or ''}; current OS is {current_os} {current_os_version or ''}".strip(),
|
|
623
|
+
stage="environment",
|
|
624
|
+
item="os",
|
|
625
|
+
expected={"name": required_os, "version": required_os_version},
|
|
626
|
+
observed={"name": current_os, "version": current_os_version},
|
|
627
|
+
evidence={"required": required_environment.get("os"), "current": current_environment.get("os")},
|
|
628
|
+
)
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
available_secrets = set(os.environ) if context.available_secrets is None else set(context.available_secrets)
|
|
632
|
+
required_secrets = manifest.get("requiredSecrets", [])
|
|
633
|
+
if not isinstance(required_secrets, list):
|
|
634
|
+
required_secrets = []
|
|
635
|
+
missing_secrets = [name for name in required_secrets if isinstance(name, str) and name not in available_secrets]
|
|
636
|
+
for name in missing_secrets:
|
|
637
|
+
findings.append(_finding("WARNING", "SECRET_MISSING", f"required Secret is missing: {name}", secret=name))
|
|
638
|
+
|
|
639
|
+
if context.available_plugins is not None:
|
|
640
|
+
available_plugins = dict(context.available_plugins)
|
|
641
|
+
for plugin in plugin_summaries:
|
|
642
|
+
artifact = plugin.get("artifact")
|
|
643
|
+
if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only":
|
|
644
|
+
continue
|
|
645
|
+
name = plugin["name"]
|
|
646
|
+
if name not in available_plugins:
|
|
647
|
+
network_source = network_source_details(plugin)
|
|
648
|
+
if context.allow_network_reference_install and network_source is not None:
|
|
649
|
+
expected_version = plugin.get("resolved", {}).get("version") if isinstance(plugin.get("resolved"), Mapping) else None
|
|
650
|
+
findings.append(
|
|
651
|
+
_finding(
|
|
652
|
+
"WARNING",
|
|
653
|
+
"NETWORK_INSTALL_REQUIRED",
|
|
654
|
+
f"reference-only plugin is not installed locally; npm will download {name}",
|
|
655
|
+
plugin=name,
|
|
656
|
+
expected={
|
|
657
|
+
"sourceType": network_source["sourceType"],
|
|
658
|
+
"specifier": network_source["specifier"],
|
|
659
|
+
"version": expected_version,
|
|
660
|
+
},
|
|
661
|
+
observed="local source unavailable",
|
|
662
|
+
evidence={"source": network_source, "resolver": "npm"},
|
|
663
|
+
impact="Import will contact the recorded network source and install into the temporary Profile.",
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
continue
|
|
667
|
+
required = plugin.get("required") is True
|
|
668
|
+
severity = "BLOCKER" if required else "WARNING"
|
|
669
|
+
code = "PLUGIN_MISSING" if required else "OPTIONAL_PLUGIN_MISSING"
|
|
670
|
+
findings.append(_finding(severity, code, f"reference-only plugin is missing: {name}", plugin=name))
|
|
671
|
+
continue
|
|
672
|
+
installed_version = available_plugins[name]
|
|
673
|
+
expected_version = plugin.get("resolved", {}).get("version") if isinstance(plugin.get("resolved"), Mapping) else None
|
|
674
|
+
if installed_version and expected_version and installed_version != expected_version:
|
|
675
|
+
findings.append(
|
|
676
|
+
_finding(
|
|
677
|
+
"BLOCKER" if plugin.get("required") is True else "WARNING",
|
|
678
|
+
"PLUGIN_VERSION_MISMATCH",
|
|
679
|
+
f"installed {name}@{installed_version} does not match Pack version {expected_version}",
|
|
680
|
+
plugin=name,
|
|
681
|
+
expected=expected_version,
|
|
682
|
+
actual=installed_version,
|
|
683
|
+
)
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
for plugin in plugin_summaries:
|
|
687
|
+
artifact = plugin.get("artifact")
|
|
688
|
+
if not isinstance(artifact, Mapping) or artifact.get("mode") != "embedded":
|
|
689
|
+
continue
|
|
690
|
+
path = artifact.get("path")
|
|
691
|
+
if not isinstance(path, str) or path not in entries:
|
|
692
|
+
continue
|
|
693
|
+
identity = _artifact_package_identity(entries[path])
|
|
694
|
+
if identity is None:
|
|
695
|
+
continue
|
|
696
|
+
plugin["artifactIdentity"] = identity
|
|
697
|
+
resolved = plugin.get("resolved")
|
|
698
|
+
expected_version = resolved.get("version") if isinstance(resolved, Mapping) else None
|
|
699
|
+
drift: dict[str, dict[str, str]] = {}
|
|
700
|
+
if identity["name"] != plugin["name"]:
|
|
701
|
+
drift["name"] = {"expected": str(plugin["name"]), "actual": identity["name"]}
|
|
702
|
+
if isinstance(expected_version, str) and identity["version"] != expected_version:
|
|
703
|
+
drift["version"] = {"expected": expected_version, "actual": identity["version"]}
|
|
704
|
+
runtime = plugin.get("runtime")
|
|
705
|
+
runtime_version = runtime.get("version") if isinstance(runtime, Mapping) else None
|
|
706
|
+
runtime_source = runtime.get("source") if isinstance(runtime, Mapping) else None
|
|
707
|
+
if (
|
|
708
|
+
isinstance(expected_version, str)
|
|
709
|
+
and isinstance(runtime_version, str)
|
|
710
|
+
and expected_version != runtime_version
|
|
711
|
+
and runtime_source != "installation-anchor"
|
|
712
|
+
):
|
|
713
|
+
drift["runtimeVersion"] = {"expected": expected_version, "actual": runtime_version}
|
|
714
|
+
if drift:
|
|
715
|
+
findings.append(
|
|
716
|
+
_finding(
|
|
717
|
+
"WARNING",
|
|
718
|
+
"PACKAGE_IDENTITY_DRIFT",
|
|
719
|
+
f"embedded package identity differs from lock identity: {plugin['name']}",
|
|
720
|
+
plugin=plugin["name"],
|
|
721
|
+
drift=drift,
|
|
722
|
+
)
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
conflicts = [
|
|
726
|
+
finding.as_dict()
|
|
727
|
+
for finding in findings
|
|
728
|
+
if finding.code.startswith("CONFLICT_")
|
|
729
|
+
]
|
|
730
|
+
required = required_environment if isinstance(required_environment, Mapping) else {}
|
|
731
|
+
environment = {"required": dict(required), "current": current_environment}
|
|
732
|
+
schema_version = manifest.get("schemaVersion")
|
|
733
|
+
schema_status = "valid" if not any(finding.code == "SCHEMA_ERROR" for finding in findings) else "invalid"
|
|
734
|
+
return PreflightResult(
|
|
735
|
+
pack_path=str(source_path),
|
|
736
|
+
status=_status(findings),
|
|
737
|
+
pack={
|
|
738
|
+
"path": str(source_path),
|
|
739
|
+
"format": manifest.get("format"),
|
|
740
|
+
"packVersion": manifest.get("packVersion"),
|
|
741
|
+
"profile": manifest.get("profile"),
|
|
742
|
+
},
|
|
743
|
+
schema={"status": schema_status, "version": schema_version},
|
|
744
|
+
environment=environment,
|
|
745
|
+
plugins=plugin_summaries,
|
|
746
|
+
secrets={
|
|
747
|
+
"required": required_secrets,
|
|
748
|
+
"missing": missing_secrets,
|
|
749
|
+
"availableNames": sorted(name for name in available_secrets if name in required_secrets),
|
|
750
|
+
},
|
|
751
|
+
artifacts=artifacts,
|
|
752
|
+
conflicts=conflicts,
|
|
753
|
+
findings=findings,
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def render_text(result: PreflightResult) -> str:
|
|
758
|
+
"""Render the stable human-readable inspect report."""
|
|
759
|
+
|
|
760
|
+
lines = [
|
|
761
|
+
"Pack",
|
|
762
|
+
f"path: {result.pack.get('path', result.pack_path)}",
|
|
763
|
+
f"format: {result.pack.get('format', 'unknown')}",
|
|
764
|
+
f"profile: {result.pack.get('profile', {}).get('name', 'unknown') if isinstance(result.pack.get('profile'), Mapping) else 'unknown'}",
|
|
765
|
+
"",
|
|
766
|
+
"Schema",
|
|
767
|
+
f"status: {result.schema.get('status', 'unknown')}",
|
|
768
|
+
f"version: {result.schema.get('version', 'unknown')}",
|
|
769
|
+
"",
|
|
770
|
+
"DSH requirement",
|
|
771
|
+
f"{_environment_value(result.environment.get('required', {}), 'dsh') or 'unknown'}",
|
|
772
|
+
"Node requirement",
|
|
773
|
+
f"{_environment_value(result.environment.get('required', {}), 'node') or 'unknown'}",
|
|
774
|
+
"OS",
|
|
775
|
+
f"{_environment_value(result.environment.get('required', {}), 'os', 'name') or 'unknown'}",
|
|
776
|
+
"",
|
|
777
|
+
"Plugins",
|
|
778
|
+
]
|
|
779
|
+
if result.plugins:
|
|
780
|
+
for plugin in result.plugins:
|
|
781
|
+
resolved = plugin.get("resolved")
|
|
782
|
+
version = resolved.get("version", "missing") if isinstance(resolved, Mapping) else "missing"
|
|
783
|
+
required = "required" if plugin.get("required") else "optional"
|
|
784
|
+
artifact = plugin.get("artifact")
|
|
785
|
+
mode = artifact.get("mode", "unknown") if isinstance(artifact, Mapping) else "unknown"
|
|
786
|
+
lines.append(f"- {plugin.get('name')}@{version} [{required}, {mode}]")
|
|
787
|
+
else:
|
|
788
|
+
lines.append("- none")
|
|
789
|
+
lines.extend(["", "Secrets"])
|
|
790
|
+
lines.append(f"required: {', '.join(result.secrets['required']) or 'none'}")
|
|
791
|
+
lines.append(f"missing: {', '.join(result.secrets['missing']) or 'none'}")
|
|
792
|
+
lines.extend(["", "Artifacts"])
|
|
793
|
+
if result.artifacts:
|
|
794
|
+
for artifact in result.artifacts:
|
|
795
|
+
lines.append(f"- {artifact.get('path') or artifact.get('plugin')} [{artifact.get('status')}, {artifact.get('mode')}]")
|
|
796
|
+
else:
|
|
797
|
+
lines.append("- none")
|
|
798
|
+
lines.extend(["", "Conflicts"])
|
|
799
|
+
if result.conflicts:
|
|
800
|
+
lines.extend(f"- {conflict['message']}" for conflict in result.conflicts)
|
|
801
|
+
else:
|
|
802
|
+
lines.append("- none")
|
|
803
|
+
for severity in _SEVERITIES:
|
|
804
|
+
lines.extend(["", severity])
|
|
805
|
+
matching = [finding for finding in result.findings if finding.severity == severity]
|
|
806
|
+
if matching:
|
|
807
|
+
lines.extend(f"- {finding.message}" for finding in matching)
|
|
808
|
+
else:
|
|
809
|
+
lines.append("- none")
|
|
810
|
+
lines.extend(["", f"Status: {result.status}"])
|
|
811
|
+
return "\n".join(lines)
|