specify-cli 1.2.3__py3-none-any.whl → 1.2.4__py3-none-any.whl

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 (43) hide show
  1. specify_cli/_install_ignore.py +4 -2
  2. specify_cli/_version.py +9 -2
  3. specify_cli/authentication/github.py +130 -9
  4. specify_cli/authentication/http.py +8 -3
  5. specify_cli/bundles/_commands.py +42 -1
  6. specify_cli/bundles/adapters.py +5 -4
  7. specify_cli/bundles/catalog_stack.py +35 -8
  8. specify_cli/bundles/catalogs.py +9 -1
  9. specify_cli/bundles/command_info.py +130 -6
  10. specify_cli/bundles/command_install.py +25 -6
  11. specify_cli/bundles/command_update.py +16 -3
  12. specify_cli/bundles/command_validate.py +72 -11
  13. specify_cli/bundles/component_catalog.py +2 -1
  14. specify_cli/bundles/conflict.py +76 -2
  15. specify_cli/bundles/expansion.py +601 -0
  16. specify_cli/bundles/installer.py +37 -12
  17. specify_cli/bundles/manifest.py +81 -3
  18. specify_cli/bundles/primitives.py +137 -38
  19. specify_cli/bundles/records.py +61 -1
  20. specify_cli/bundles/resolver.py +69 -9
  21. specify_cli/bundles/sources.py +105 -1
  22. specify_cli/bundles/validator.py +8 -3
  23. specify_cli/bundles/versioning.py +11 -0
  24. specify_cli/command_init.py +1 -1
  25. specify_cli/events/__init__.py +1 -1
  26. specify_cli/integrations/junie/__init__.py +29 -0
  27. specify_cli/integrations/vibe/__init__.py +54 -4
  28. specify_cli/presets/_resolver.py +1 -1
  29. specify_cli/workflows/expressions/__init__.py +63 -0
  30. specify_cli/workflows/expressions/api.py +70 -0
  31. specify_cli/workflows/expressions/diagnostics.py +323 -0
  32. specify_cli/workflows/expressions/engine.py +34 -0
  33. specify_cli/workflows/expressions/engine_current.py +548 -0
  34. specify_cli/workflows/expressions/filters.py +291 -0
  35. specify_cli/workflows/expressions/namespace.py +85 -0
  36. specify_cli/workflows/expressions/operators.py +71 -0
  37. specify_cli/workflows/expressions/template.py +322 -0
  38. {specify_cli-1.2.3.dist-info → specify_cli-1.2.4.dist-info}/METADATA +1 -1
  39. {specify_cli-1.2.3.dist-info → specify_cli-1.2.4.dist-info}/RECORD +42 -33
  40. specify_cli/workflows/expressions.py +0 -1581
  41. {specify_cli-1.2.3.dist-info → specify_cli-1.2.4.dist-info}/WHEEL +0 -0
  42. {specify_cli-1.2.3.dist-info → specify_cli-1.2.4.dist-info}/entry_points.txt +0 -0
  43. {specify_cli-1.2.3.dist-info → specify_cli-1.2.4.dist-info}/licenses/LICENSE +0 -0
@@ -17,14 +17,16 @@ def load_install_ignore(
17
17
  Match the extension installer's Git-style patterns, including whitespace
18
18
  trimming and backslash normalization. Only the named root ignore file is
19
19
  read; there are no default exclusions except the ignore filename itself.
20
- Invalid encoding or patterns raise the caller's domain validation error
21
- before copying.
20
+ A non-file path, invalid encoding, or invalid patterns raise the caller's
21
+ domain validation error before copying.
22
22
  Excluded directories are pruned by copytree, so child negations cannot
23
23
  restore files inside them unless the parent directory is also re-included.
24
24
  """
25
25
  ignore_file = source_dir / ignore_filename
26
26
  if not ignore_file.exists():
27
27
  return None
28
+ if not ignore_file.is_file():
29
+ raise validation_error(f"{ignore_filename} is not a regular file: {ignore_file}")
28
30
 
29
31
  # Accept UTF-8 with or without the BOM emitted by Windows PowerShell 5.1.
30
32
  # Strip it after decoding so error offsets remain relative to the file.
specify_cli/_version.py CHANGED
@@ -37,6 +37,7 @@ _RESOLUTION_FAILURE_RATE_LIMITED = (
37
37
  "rate limited (configure ~/.specify/auth.json with a GitHub token)"
38
38
  )
39
39
  _RESOLUTION_FAILURE_HTTP_PREFIX = "HTTP "
40
+ _RESOLUTION_FAILURE_AUTH_PREFIX = "authentication error: "
40
41
  _FAILURE_INSTALLER_MISSING = "installer-missing"
41
42
  _FAILURE_INSTALLER_INVALID = "installer-invalid"
42
43
  _FAILURE_TARGET_TAG_UNPARSEABLE = "target-tag-unparseable"
@@ -108,11 +109,12 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
108
109
  """Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
109
110
 
110
111
  On success: (tag_name, None).
111
- On a documented network/HTTP failure (added in T029/T030): (None, category).
112
+ On a documented authentication/network/HTTP failure: (None, category).
112
113
  On anything else — including a malformed response body — the exception
113
114
  propagates; there is no catch-all (research D-006).
114
115
  """
115
116
  from .authentication.http import open_url
117
+ from .authentication.github import GitHubCliAuthError
116
118
 
117
119
  try:
118
120
  with open_url(
@@ -131,6 +133,8 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
131
133
  if not isinstance(tag, str) or not tag:
132
134
  raise ValueError("GitHub API response missing valid tag_name")
133
135
  return tag, None
136
+ except GitHubCliAuthError as exc:
137
+ return None, f"{_RESOLUTION_FAILURE_AUTH_PREFIX}{exc.reason}"
134
138
  except urllib.error.HTTPError as e:
135
139
  # Order matters: HTTPError is a subclass of URLError.
136
140
  # 403 (primary rate limit / abuse detection) and 429 (Too Many Requests /
@@ -1021,7 +1025,10 @@ def _emit_failure(
1021
1025
  """Render user-facing output for resolver, installer, or verification failures."""
1022
1026
  if (
1023
1027
  category in _RESOLUTION_FAILURE_CATEGORIES
1024
- or category.startswith(_RESOLUTION_FAILURE_HTTP_PREFIX)
1028
+ or category.startswith((
1029
+ _RESOLUTION_FAILURE_HTTP_PREFIX,
1030
+ _RESOLUTION_FAILURE_AUTH_PREFIX,
1031
+ ))
1025
1032
  ):
1026
1033
  console.print(f"Upgrade aborted: {category}", soft_wrap=True)
1027
1034
  return
@@ -2,23 +2,144 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from typing import TYPE_CHECKING
9
+ from urllib.error import URLError
10
+
5
11
  from .base import AuthProvider
6
12
 
13
+ if TYPE_CHECKING:
14
+ from .config import AuthConfigEntry
7
15
 
8
- class GitHubAuth(AuthProvider):
9
- """GitHub authentication provider.
10
16
 
11
- Supports the ``bearer`` auth scheme, used for PATs, fine-grained PATs,
12
- OAuth tokens, and GitHub App installation tokens.
13
- """
17
+ _GITHUB_CLI_TIMEOUT_SECONDS = 10
18
+
19
+
20
+ def _is_safe_cli_hostname(hostname: str) -> bool:
21
+ """Return whether *hostname* is a shell-safe DNS hostname."""
22
+ if not hostname or len(hostname) > 253 or not hostname.isascii():
23
+ return False
24
+ labels = hostname.split(".")
25
+ return all(
26
+ 0 < len(label) <= 63
27
+ and label[0].isalnum()
28
+ and label[-1].isalnum()
29
+ and all(char.isalnum() or char == "-" for char in label)
30
+ for label in labels
31
+ )
32
+
33
+
34
+ class GitHubCliAuthError(URLError):
35
+ """Raised when GitHub CLI cannot provide a configured credential."""
36
+
37
+
38
+ class GitHubAuth(AuthProvider):
39
+ """GitHub authentication provider."""
14
40
 
15
41
  key = "github"
16
- supported_auth_schemes = ("bearer",)
42
+ supported_auth_schemes = ("bearer", "github-cli")
43
+
44
+ def __init__(self) -> None:
45
+ self._github_cli_cache: dict[str, tuple[bool, str]] = {}
17
46
 
18
47
  def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
19
- """Return ``Authorization: Bearer <token>``."""
20
- if auth_scheme != "bearer":
48
+ """Return a bearer authorization header."""
49
+ if auth_scheme not in self.supported_auth_schemes:
21
50
  raise ValueError(
22
51
  f"GitHubAuth does not support auth scheme {auth_scheme!r}"
23
52
  )
24
- return {"Authorization": f"Bearer {token}"}
53
+ return {"Authorization": "Bearer " + token}
54
+
55
+ def resolve_token(self, entry: AuthConfigEntry) -> str | None:
56
+ """Resolve a configured token or acquire one from GitHub CLI."""
57
+ if entry.auth != "github-cli":
58
+ return super().resolve_token(entry)
59
+
60
+ credential_host = self._credential_hostname(entry)
61
+ cached = self._github_cli_cache.get(credential_host)
62
+ if cached is not None:
63
+ succeeded, value = cached
64
+ if succeeded:
65
+ return value
66
+ raise GitHubCliAuthError(value)
67
+
68
+ try:
69
+ token = self._acquire_via_gh_cli(credential_host)
70
+ except GitHubCliAuthError as exc:
71
+ message = str(exc.reason)
72
+ self._github_cli_cache[credential_host] = (False, message)
73
+ raise GitHubCliAuthError(message) from None
74
+
75
+ self._github_cli_cache[credential_host] = (True, token)
76
+ return token
77
+
78
+ @staticmethod
79
+ def _credential_hostname(entry: AuthConfigEntry) -> str:
80
+ """Use the first configured host for GitHub CLI credential acquisition."""
81
+ hostname = entry.hosts[0]
82
+ if "*" in hostname:
83
+ raise GitHubCliAuthError(
84
+ "GitHub authentication failed for provider 'github': "
85
+ "the first hostname in a github-cli entry must be an exact "
86
+ "hostname, not a wildcard."
87
+ )
88
+ return hostname
89
+
90
+ @staticmethod
91
+ def _acquire_via_gh_cli(hostname: str) -> str:
92
+ """Run the fixed GitHub CLI token command for *hostname*."""
93
+ if not _is_safe_cli_hostname(hostname):
94
+ raise GitHubCliAuthError(
95
+ "GitHub authentication failed for provider 'github': "
96
+ "the configured hostname is not valid for GitHub CLI authentication."
97
+ )
98
+
99
+ resolved = shutil.which("gh")
100
+ if not resolved or not os.path.isabs(resolved):
101
+ raise GitHubCliAuthError(
102
+ "GitHub authentication failed for provider 'github': "
103
+ "GitHub CLI ('gh') was not found."
104
+ )
105
+
106
+ try:
107
+ result = subprocess.run( # noqa: S603
108
+ [resolved, "auth", "token", "--hostname", hostname],
109
+ capture_output=True,
110
+ text=True,
111
+ timeout=_GITHUB_CLI_TIMEOUT_SECONDS,
112
+ check=False,
113
+ shell=False,
114
+ )
115
+ except subprocess.TimeoutExpired:
116
+ raise GitHubCliAuthError(
117
+ "GitHub authentication failed for provider 'github': "
118
+ f"GitHub CLI timed out while requesting a token for {hostname!r}."
119
+ ) from None
120
+ except (OSError, UnicodeDecodeError):
121
+ raise GitHubCliAuthError(
122
+ "GitHub authentication failed for provider 'github': "
123
+ f"GitHub CLI could not provide a token for {hostname!r}."
124
+ ) from None
125
+
126
+ if result.returncode != 0:
127
+ raise GitHubCliAuthError(
128
+ "GitHub authentication failed for provider 'github': "
129
+ f"GitHub CLI could not provide a token for {hostname!r}. "
130
+ f"Run 'gh auth login --hostname {hostname}'."
131
+ )
132
+
133
+ token = result.stdout.strip()
134
+ if not token:
135
+ raise GitHubCliAuthError(
136
+ "GitHub authentication failed for provider 'github': "
137
+ f"GitHub CLI returned an empty token for {hostname!r}. "
138
+ f"Run 'gh auth login --hostname {hostname}'."
139
+ )
140
+ if not token.isascii() or "\r" in token or "\n" in token:
141
+ raise GitHubCliAuthError(
142
+ "GitHub authentication failed for provider 'github': "
143
+ f"GitHub CLI returned a malformed token for {hostname!r}."
144
+ )
145
+ return token
@@ -133,6 +133,8 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
133
133
 
134
134
  Uses the first matching entry from ``auth.json`` whose token resolves.
135
135
  Returns a plain request when no entry matches or the file doesn't exist.
136
+ A ``github-cli`` acquisition failure raises immediately, without trying
137
+ later matching entries or returning an unauthenticated request.
136
138
  """
137
139
  headers: dict[str, str] = {}
138
140
  if extra_headers:
@@ -174,9 +176,12 @@ def open_url(
174
176
  """Open *url* with config-driven auth, redirect stripping, and fallthrough.
175
177
 
176
178
  1. Find ``auth.json`` entries whose hosts match the URL.
177
- 2. For each entry, resolve the token and try the request.
178
- 3. On 401/403 move to the next matching entry.
179
- 4. After all entries exhausted (or none matched), try unauthenticated.
179
+ 2. For each entry, resolve the token and try the request. A ``github-cli``
180
+ acquisition failure raises immediately, with no credential or
181
+ unauthenticated fallback.
182
+ 3. On HTTP 401/403 after successful acquisition, try the next matching entry.
183
+ 4. After all entries exhausted without an acquisition failure (or none
184
+ matched), try unauthenticated.
180
185
  5. Non-auth errors (404, 500, network) raise immediately.
181
186
 
182
187
  *extra_headers* (e.g. ``Accept``) are merged into every attempt.
@@ -46,6 +46,13 @@ def _fail(message: str, *, as_json: bool = False) -> None:
46
46
  raise typer.Exit(code=1)
47
47
 
48
48
 
49
+ def _fail_with_notes(exc: BaseException) -> None:
50
+ """Exit with the original error first, then any recovery notes."""
51
+ notes = [str(note) for note in getattr(exc, "__notes__", ())]
52
+ message = str(exc) if not notes else f"{exc}\n" + "\n".join(notes)
53
+ _fail(message)
54
+
55
+
49
56
  def _render_install_warnings(result: InstallResult) -> None:
50
57
  for warning in result.warnings:
51
58
  err_console.print(f"[yellow]Warning:[/yellow] {_escape_markup(warning.message)}")
@@ -139,7 +146,40 @@ def _resolve_init_integration(override: str | None, manifest) -> str:
139
146
  return resolve_default_init_integration()
140
147
 
141
148
 
142
- def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str]:
149
+ def _expand_manifest(
150
+ project_root: Path,
151
+ manifest,
152
+ *,
153
+ offline: bool,
154
+ stack=None,
155
+ download,
156
+ ):
157
+ """Expand referenced bundles through the effective catalog stack.
158
+
159
+ Flat manifests deliberately bypass stack construction: local bundles with
160
+ no references must retain their existing offline, no-network behavior.
161
+ ``download`` is supplied by the calling command so its established
162
+ monkeypatch point also controls referenced-manifest downloads.
163
+ """
164
+ from .expansion import ExpandedManifest, expand_manifest
165
+
166
+ if not manifest.bundles:
167
+ return ExpandedManifest.flat(manifest)
168
+ from .sources import CatalogBundleLoader
169
+
170
+ if stack is None:
171
+ stack = _build_stack(project_root, offline=offline)
172
+ loader = CatalogBundleLoader(stack, offline=offline, download=download)
173
+ return expand_manifest(manifest, loader)
174
+
175
+
176
+ def _bundle_overlaps(
177
+ project_root: Path,
178
+ manifest,
179
+ *,
180
+ offline: bool,
181
+ components=None,
182
+ ) -> list[str]:
143
183
  """Return informational overlaps between *manifest* and installed bundles."""
144
184
  if manifest is None:
145
185
  return []
@@ -150,6 +190,7 @@ def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str
150
190
  manifest,
151
191
  active_integration(project_root),
152
192
  load_records(project_root),
193
+ components=components,
153
194
  )
154
195
  return [*report.overlaps, *report.version_clashes]
155
196
  except BundlerError:
@@ -159,7 +159,7 @@ def make_catalog_fetcher(*, allow_network: bool = True):
159
159
  ) from None
160
160
  scheme = parsed.scheme.lower()
161
161
 
162
- if scheme == "builtin":
162
+ if source.is_builtin:
163
163
  repository_url = _BUILTIN_REPOSITORY_URLS.get(url)
164
164
  if repository_url is None:
165
165
  raise BundlerError(f"Unknown built-in catalog '{url}'.")
@@ -228,6 +228,7 @@ def _http_get_json(source_id: str, url: str) -> dict:
228
228
  HTTPS/host guarantee from ``_validate_remote_url`` is preserved end to end
229
229
  rather than only on the initial URL.
230
230
  """
231
+ from ..authentication.github import GitHubCliAuthError
231
232
  from ..authentication.http import RedirectPolicyError, open_url
232
233
 
233
234
  def _validate_redirect(_old_url: str, new_url: str) -> None:
@@ -258,9 +259,9 @@ def _http_get_json(source_id: str, url: str) -> dict:
258
259
  raise BundlerError(
259
260
  f"Failed to fetch catalog from {url}: {exc}"
260
261
  ) from exc
261
- except RedirectPolicyError as exc:
262
- # Unsafe/malformed redirects are security failures and must surface as
263
- # a hard error instead of being downgraded to catalog unavailability.
262
+ except (GitHubCliAuthError, RedirectPolicyError) as exc:
263
+ # Authentication and redirect-policy failures are definitive, not
264
+ # transient catalog unavailability.
264
265
  raise BundlerError(
265
266
  f"Failed to fetch catalog from {url}: {exc}"
266
267
  ) from exc
@@ -22,6 +22,35 @@ from .catalogs import (
22
22
  CatalogFetcher = Callable[[CatalogSource], dict]
23
23
 
24
24
 
25
+ class BundleNotFoundError(BundlerError):
26
+ """A bundle ID is absent from every configured catalog."""
27
+
28
+ def __init__(self, bundle_id: str) -> None:
29
+ super().__init__(f"Bundle '{bundle_id}' was not found in any configured catalog.")
30
+
31
+
32
+ class ReleaseNotFoundError(BundlerError):
33
+ """A requested release is absent from the winning catalog entry."""
34
+
35
+ def __init__(
36
+ self,
37
+ bundle_id: str,
38
+ version: str,
39
+ source_id: str,
40
+ advertised: tuple[str, ...],
41
+ ) -> None:
42
+ self.bundle_id = bundle_id
43
+ self.version = version
44
+ self.source_id = source_id
45
+ self.advertised = advertised
46
+ offered = ", ".join(advertised)
47
+ super().__init__(
48
+ f"Bundle '{bundle_id}' has no release {version} in catalog source "
49
+ f"'{source_id}' (advertised: {offered}). Only the "
50
+ "highest-precedence source that lists a bundle is consulted."
51
+ )
52
+
53
+
25
54
  @dataclass
26
55
  class ResolvedBundle:
27
56
  entry: CatalogEntry
@@ -80,19 +109,17 @@ class CatalogStack:
80
109
  if entry is not None:
81
110
  selected = select_release(entry, version)
82
111
  if selected is None:
83
- advertised = ", ".join(available_versions(entry))
84
- raise BundlerError(
85
- f"Bundle '{bundle_id}' has no release {version} in catalog source "
86
- f"'{source.id}' (advertised: {advertised}). Only the "
87
- "highest-precedence source that lists a bundle is consulted."
112
+ raise ReleaseNotFoundError(
113
+ bundle_id=bundle_id,
114
+ version=version,
115
+ source_id=source.id,
116
+ advertised=tuple(available_versions(entry)),
88
117
  )
89
118
  return ResolvedBundle(
90
119
  entry=selected.with_provenance(source),
91
120
  source=source,
92
121
  )
93
- raise BundlerError(
94
- f"Bundle '{bundle_id}' was not found in any configured catalog."
95
- )
122
+ raise BundleNotFoundError(bundle_id)
96
123
 
97
124
  def search(self, query: str = "") -> list[ResolvedBundle]:
98
125
  """Return entries matching *query* (substring over id/name/role/tags/description).
@@ -10,7 +10,7 @@ from dataclasses import dataclass, field, replace
10
10
  from enum import Enum
11
11
  from pathlib import Path
12
12
  from typing import Any
13
-
13
+ from urllib.parse import urlparse
14
14
  from . import BundlerError
15
15
  from .manifest import _text
16
16
  from .yamlio import ensure_within, load_yaml
@@ -67,6 +67,14 @@ class CatalogSource:
67
67
  def install_allowed(self) -> bool:
68
68
  return self.install_policy is InstallPolicy.INSTALL_ALLOWED
69
69
 
70
+ @property
71
+ def is_builtin(self) -> bool:
72
+ """Whether this source is a ``builtin://`` catalog (packaged snapshot offline)."""
73
+ try:
74
+ return urlparse(self.url).scheme.lower() == "builtin"
75
+ except ValueError:
76
+ return False
77
+
70
78
  @classmethod
71
79
  def from_dict(cls, data: Any, scope: Scope) -> "CatalogSource":
72
80
  if not isinstance(data, dict):
@@ -14,13 +14,16 @@ from . import BundlerError
14
14
  from ._commands import (
15
15
  _build_stack,
16
16
  _bundle_overlaps,
17
+ _expand_manifest,
17
18
  _fail,
18
19
  _trust_badge,
19
20
  _trust_level,
20
21
  bundle_app,
21
22
  )
23
+ from .conflict import effective_setting
22
24
  from .project import find_project_root
23
25
  from .sources import _download_manifest
26
+ from .versioning import same_version, strip_version_prefix
24
27
 
25
28
 
26
29
  @bundle_app.command("info")
@@ -77,12 +80,28 @@ def bundle_info(
77
80
  # degrading to catalog `provides` counts, so users never mistake an
78
81
  # unverifiable bundle for a known/installable one.
79
82
  manifest = _download_manifest(resolved, offline=offline)
83
+ expanded = _expand_manifest(
84
+ project_root,
85
+ manifest,
86
+ offline=offline,
87
+ stack=stack,
88
+ download=_download_manifest,
89
+ )
80
90
  except BundlerError as exc:
81
91
  _fail(str(exc), as_json=as_json and isinstance(exc.__cause__, ProjectPolicyError))
82
92
  return
83
93
 
84
- overlaps = _bundle_overlaps(project_root, manifest, offline=offline)
85
- components = _manifest_component_view(manifest)
94
+ if expanded.has_references:
95
+ overlaps = _bundle_overlaps(
96
+ project_root,
97
+ manifest,
98
+ offline=offline,
99
+ components=expanded.components,
100
+ )
101
+ else:
102
+ # Preserve the flat command's established call shape and rendering.
103
+ overlaps = _bundle_overlaps(project_root, manifest, offline=offline)
104
+ components = _manifest_component_view(expanded)
86
105
 
87
106
  entry = resolved.entry
88
107
  if as_json:
@@ -106,6 +125,8 @@ def bundle_info(
106
125
  "components": components,
107
126
  "overlaps": overlaps,
108
127
  }
128
+ if expanded.has_references:
129
+ payload["references"] = _reference_view(expanded)
109
130
  print(_json.dumps(payload, indent=2))
110
131
  return
111
132
 
@@ -133,6 +154,11 @@ def bundle_info(
133
154
  if manifest and manifest.integration:
134
155
  console.print(f" Integration: {_escape_markup(str(manifest.integration.id))}")
135
156
 
157
+ if expanded.has_references:
158
+ console.print("\n [bold]References:[/bold]")
159
+ for reference in expanded.references:
160
+ console.print(f" - {_escape_markup(_format_reference(reference))}")
161
+
136
162
  if components:
137
163
  console.print("\n [bold]Components[/bold] (added on install):")
138
164
  for kind in ("extensions", "presets", "steps", "workflows"):
@@ -161,25 +187,82 @@ def bundle_info(
161
187
  )
162
188
 
163
189
 
164
- def _manifest_component_view(manifest) -> list[dict]:
165
- """Flatten a manifest's components to JSON-friendly dicts (id, version, ...)."""
166
- if manifest is None:
190
+ def _manifest_component_view(expanded) -> list[dict]:
191
+ """Flatten an expanded manifest to JSON-friendly component dictionaries."""
192
+ if expanded is None:
167
193
  return []
168
194
  view: list[dict] = []
169
- for component in manifest.components:
195
+ for component in expanded.components:
170
196
  item = {
171
197
  "kind": component.kind,
172
198
  "id": component.id,
173
199
  "version": component.version,
174
200
  }
201
+ if expanded.has_references and component.source is not None:
202
+ item["source"] = component.source
175
203
  if component.priority is not None:
176
204
  item["priority"] = component.priority
177
205
  if component.strategy is not None:
178
206
  item["strategy"] = component.strategy
207
+ if expanded.has_references:
208
+ origin = expanded.origins.get((component.kind, component.id))
209
+ if origin is not None and origin.origin:
210
+ item["origin"] = list(origin.origin)
211
+ if origin is not None and origin.overrides:
212
+ item["overrides"] = [
213
+ _component_provenance(override.component, override.origin)
214
+ for override in origin.overrides
215
+ ]
179
216
  view.append(item)
180
217
  return view
181
218
 
182
219
 
220
+ def _component_provenance(component, origin: tuple[str, ...]) -> dict:
221
+ item = {
222
+ "version": component.version,
223
+ "origin": list(origin),
224
+ }
225
+ if component.source is not None:
226
+ item["source"] = component.source
227
+ if component.priority is not None:
228
+ item["priority"] = component.priority
229
+ if component.strategy is not None:
230
+ item["strategy"] = component.strategy
231
+ return item
232
+
233
+
234
+ def _reference_view(expanded) -> list[dict]:
235
+ return [
236
+ {
237
+ "id": reference.id,
238
+ "version": reference.version,
239
+ "source": reference.source_id,
240
+ "install_policy": (
241
+ "install-allowed" if reference.install_allowed else "discovery-only"
242
+ ),
243
+ "via": list(reference.via),
244
+ "verified": reference.verified,
245
+ "trust": _trust_level(reference.verified),
246
+ }
247
+ for reference in expanded.references
248
+ ]
249
+
250
+
251
+ def _format_reference(reference) -> str:
252
+ # The label supplies its own "v", and an accepted pin may already carry one
253
+ # (``v1.0.1``); strip it here so the text shows one prefix. The JSON view
254
+ # keeps the recorded spelling.
255
+ label = f"{reference.id} v{strip_version_prefix(reference.version)}"
256
+ label += f" ({reference.source_id}"
257
+ label += f"; {_trust_level(reference.verified)}"
258
+ if reference.via:
259
+ label += f"; via {' → '.join(reference.via)}"
260
+ if not reference.install_allowed:
261
+ label += "; discovery-only, not installable"
262
+ label += ")"
263
+ return label
264
+
265
+
183
266
  def _format_component(item: dict) -> str:
184
267
  label = f"{item['id']} v{item['version']}" if item.get("version") else item["id"]
185
268
  extras = []
@@ -187,6 +270,47 @@ def _format_component(item: dict) -> str:
187
270
  extras.append(f"priority={item['priority']}")
188
271
  if item.get("strategy") is not None:
189
272
  extras.append(f"strategy={item['strategy']}")
273
+ origin = item.get("origin")
274
+ if origin:
275
+ extras.append(f"from {' → '.join(origin)}")
276
+ for override in item.get("overrides", []):
277
+ extras.append(_format_override(item, override))
190
278
  if extras:
191
279
  label += f" ({', '.join(extras)})"
192
280
  return label
281
+
282
+
283
+ def _format_override(item: dict, override: dict) -> str:
284
+ """Describe the settings replaced by an overriding component."""
285
+ changed: list[str] = []
286
+ old_version = override.get("version")
287
+ current_version = item.get("version")
288
+ versions_differ = (old_version is None) != (current_version is None)
289
+ if (
290
+ versions_differ
291
+ or (
292
+ old_version is not None
293
+ and current_version is not None
294
+ and not same_version(current_version, old_version)
295
+ )
296
+ ):
297
+ changed.append(str(old_version) if old_version else "unpinned")
298
+
299
+ kind = item.get("kind")
300
+ for field in ("source", "priority", "strategy"):
301
+ if kind is None:
302
+ # Provenance without a kind compares raw values.
303
+ differs = override.get(field) != item.get(field)
304
+ else:
305
+ differs = effective_setting(
306
+ kind, field, override.get(field)
307
+ ) != effective_setting(kind, field, item.get(field))
308
+ if differs:
309
+ value = override.get(field)
310
+ changed.append(f"{field}={value if value is not None else '<unset>'}")
311
+
312
+ origin = " → ".join(override.get("origin", []))
313
+ if not changed:
314
+ # Equivalent copies stay in the history; don't invent a difference.
315
+ return f"also declared by {origin} (same as final)"
316
+ return f"overrides {', '.join(changed)} from {origin}"