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.
- specify_cli/_install_ignore.py +4 -2
- specify_cli/_version.py +9 -2
- specify_cli/authentication/github.py +130 -9
- specify_cli/authentication/http.py +8 -3
- specify_cli/bundles/_commands.py +42 -1
- specify_cli/bundles/adapters.py +5 -4
- specify_cli/bundles/catalog_stack.py +35 -8
- specify_cli/bundles/catalogs.py +9 -1
- specify_cli/bundles/command_info.py +130 -6
- specify_cli/bundles/command_install.py +25 -6
- specify_cli/bundles/command_update.py +16 -3
- specify_cli/bundles/command_validate.py +72 -11
- specify_cli/bundles/component_catalog.py +2 -1
- specify_cli/bundles/conflict.py +76 -2
- specify_cli/bundles/expansion.py +601 -0
- specify_cli/bundles/installer.py +37 -12
- specify_cli/bundles/manifest.py +81 -3
- specify_cli/bundles/primitives.py +137 -38
- specify_cli/bundles/records.py +61 -1
- specify_cli/bundles/resolver.py +69 -9
- specify_cli/bundles/sources.py +105 -1
- specify_cli/bundles/validator.py +8 -3
- specify_cli/bundles/versioning.py +11 -0
- specify_cli/command_init.py +1 -1
- specify_cli/events/__init__.py +1 -1
- specify_cli/integrations/junie/__init__.py +29 -0
- specify_cli/integrations/vibe/__init__.py +54 -4
- specify_cli/presets/_resolver.py +1 -1
- specify_cli/workflows/expressions/__init__.py +63 -0
- specify_cli/workflows/expressions/api.py +70 -0
- specify_cli/workflows/expressions/diagnostics.py +323 -0
- specify_cli/workflows/expressions/engine.py +34 -0
- specify_cli/workflows/expressions/engine_current.py +548 -0
- specify_cli/workflows/expressions/filters.py +291 -0
- specify_cli/workflows/expressions/namespace.py +85 -0
- specify_cli/workflows/expressions/operators.py +71 -0
- specify_cli/workflows/expressions/template.py +322 -0
- {specify_cli-1.2.3.dist-info → specify_cli-1.2.4.dist-info}/METADATA +1 -1
- {specify_cli-1.2.3.dist-info → specify_cli-1.2.4.dist-info}/RECORD +42 -33
- specify_cli/workflows/expressions.py +0 -1581
- {specify_cli-1.2.3.dist-info → specify_cli-1.2.4.dist-info}/WHEEL +0 -0
- {specify_cli-1.2.3.dist-info → specify_cli-1.2.4.dist-info}/entry_points.txt +0 -0
- {specify_cli-1.2.3.dist-info → specify_cli-1.2.4.dist-info}/licenses/LICENSE +0 -0
specify_cli/_install_ignore.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
20
|
-
if auth_scheme
|
|
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":
|
|
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
|
-
|
|
179
|
-
|
|
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.
|
specify_cli/bundles/_commands.py
CHANGED
|
@@ -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
|
|
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:
|
specify_cli/bundles/adapters.py
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
263
|
-
#
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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).
|
specify_cli/bundles/catalogs.py
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
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(
|
|
165
|
-
"""Flatten
|
|
166
|
-
if
|
|
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
|
|
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}"
|