specify-cli 1.2.2__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 +89 -0
- 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_check.py +2 -2
- specify_cli/command_init.py +19 -1
- specify_cli/core_pack/extensions/github/README.md +2 -2
- specify_cli/core_pack/extensions/github/extension.yml +1 -1
- specify_cli/core_pack/scripts/powershell/check-prerequisites.ps1 +8 -8
- specify_cli/core_pack/scripts/powershell/common.ps1 +2 -2
- specify_cli/core_pack/scripts/powershell/setup-tasks.ps1 +6 -6
- specify_cli/core_pack/scripts/python/check_prerequisites.py +2 -1
- specify_cli/core_pack/scripts/python/resolve_template.py +2 -1
- specify_cli/core_pack/scripts/python/setup_plan.py +2 -1
- specify_cli/core_pack/scripts/python/setup_tasks.py +2 -1
- specify_cli/events/__init__.py +91 -165
- specify_cli/extensions/__init__.py +2 -62
- specify_cli/extensions/catalog/command_remove.py +8 -1
- specify_cli/integrations/__init__.py +2 -0
- specify_cli/integrations/base.py +25 -10
- specify_cli/integrations/command_list.py +1 -1
- specify_cli/integrations/command_upgrade.py +1 -0
- specify_cli/integrations/copilot/__init__.py +2 -1
- specify_cli/integrations/junie/__init__.py +29 -0
- specify_cli/integrations/kimi/__init__.py +29 -0
- specify_cli/integrations/none/__init__.py +29 -0
- specify_cli/integrations/pi/__init__.py +34 -0
- specify_cli/integrations/vibe/__init__.py +448 -5
- specify_cli/presets/_manager.py +5 -1
- specify_cli/presets/_resolver.py +1 -1
- specify_cli/workflows/_mcp.py +377 -0
- specify_cli/workflows/engine.py +9 -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.2.dist-info → specify_cli-1.2.4.dist-info}/METADATA +69 -48
- {specify_cli-1.2.2.dist-info → specify_cli-1.2.4.dist-info}/RECORD +65 -53
- specify_cli/workflows/expressions.py +0 -1581
- {specify_cli-1.2.2.dist-info → specify_cli-1.2.4.dist-info}/WHEEL +0 -0
- {specify_cli-1.2.2.dist-info → specify_cli-1.2.4.dist-info}/entry_points.txt +0 -0
- {specify_cli-1.2.2.dist-info → specify_cli-1.2.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Shared source-directory installation filtering for extensions and presets."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable, List, Optional, Set
|
|
6
|
+
|
|
7
|
+
import pathspec
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_install_ignore(
|
|
11
|
+
source_dir: Path,
|
|
12
|
+
ignore_filename: str,
|
|
13
|
+
validation_error: type[Exception],
|
|
14
|
+
) -> Optional[Callable[[str, List[str]], Set[str]]]:
|
|
15
|
+
"""Load a UTF-8 ignore file as a copytree callback, or return None if absent.
|
|
16
|
+
|
|
17
|
+
Match the extension installer's Git-style patterns, including whitespace
|
|
18
|
+
trimming and backslash normalization. Only the named root ignore file is
|
|
19
|
+
read; there are no default exclusions except the ignore filename itself.
|
|
20
|
+
A non-file path, invalid encoding, or invalid patterns raise the caller's
|
|
21
|
+
domain validation error before copying.
|
|
22
|
+
Excluded directories are pruned by copytree, so child negations cannot
|
|
23
|
+
restore files inside them unless the parent directory is also re-included.
|
|
24
|
+
"""
|
|
25
|
+
ignore_file = source_dir / ignore_filename
|
|
26
|
+
if not ignore_file.exists():
|
|
27
|
+
return None
|
|
28
|
+
if not ignore_file.is_file():
|
|
29
|
+
raise validation_error(f"{ignore_filename} is not a regular file: {ignore_file}")
|
|
30
|
+
|
|
31
|
+
# Accept UTF-8 with or without the BOM emitted by Windows PowerShell 5.1.
|
|
32
|
+
# Strip it after decoding so error offsets remain relative to the file.
|
|
33
|
+
try:
|
|
34
|
+
raw = ignore_file.read_text(encoding="utf-8").removeprefix("\ufeff")
|
|
35
|
+
except UnicodeDecodeError as e:
|
|
36
|
+
raise validation_error(
|
|
37
|
+
f"{ignore_filename} is not valid UTF-8: {ignore_file} "
|
|
38
|
+
f"({e.reason} at byte {e.start})"
|
|
39
|
+
)
|
|
40
|
+
lines: List[str] = raw.splitlines()
|
|
41
|
+
|
|
42
|
+
# Normalise backslashes in patterns so Windows-authored files work
|
|
43
|
+
normalised: List[str] = []
|
|
44
|
+
for line in lines:
|
|
45
|
+
stripped = line.strip()
|
|
46
|
+
if stripped and not stripped.startswith("#"):
|
|
47
|
+
normalised.append(stripped.replace("\\", "/"))
|
|
48
|
+
else:
|
|
49
|
+
# Preserve blanks/comments so pathspec line numbers stay stable
|
|
50
|
+
normalised.append(line)
|
|
51
|
+
|
|
52
|
+
# Always ignore the ignore file itself, even after a negation pattern.
|
|
53
|
+
normalised.append(ignore_filename)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
spec = pathspec.GitIgnoreSpec.from_lines(normalised)
|
|
57
|
+
except (ValueError, re.error) as e:
|
|
58
|
+
# Locate the first malformed source rule only on failure. Valid files
|
|
59
|
+
# compile once; the appended self-exclusion is not a source line.
|
|
60
|
+
location = ""
|
|
61
|
+
for line_number, pattern in enumerate(normalised[:len(lines)], start=1):
|
|
62
|
+
try:
|
|
63
|
+
pathspec.GitIgnoreSpec.from_lines([pattern])
|
|
64
|
+
except (ValueError, re.error):
|
|
65
|
+
location = f" at line {line_number}: {pattern!r}"
|
|
66
|
+
break
|
|
67
|
+
raise validation_error(
|
|
68
|
+
f"Invalid patterns in {ignore_filename}: {ignore_file}{location} ({e})"
|
|
69
|
+
) from e
|
|
70
|
+
|
|
71
|
+
def _ignore(directory: str, entries: List[str]) -> Set[str]:
|
|
72
|
+
ignored: Set[str] = set()
|
|
73
|
+
rel_dir = Path(directory).relative_to(source_dir)
|
|
74
|
+
for entry in entries:
|
|
75
|
+
rel_path = str(rel_dir / entry) if str(rel_dir) != "." else entry
|
|
76
|
+
# Normalise to forward slashes for consistent matching
|
|
77
|
+
rel_path_fwd = rel_path.replace("\\", "/")
|
|
78
|
+
|
|
79
|
+
entry_full = Path(directory) / entry
|
|
80
|
+
if entry_full.is_dir():
|
|
81
|
+
# Append '/' so directory-only patterns (e.g. tests/) match
|
|
82
|
+
if spec.match_file(rel_path_fwd + "/"):
|
|
83
|
+
ignored.add(entry)
|
|
84
|
+
else:
|
|
85
|
+
if spec.match_file(rel_path_fwd):
|
|
86
|
+
ignored.add(entry)
|
|
87
|
+
return ignored
|
|
88
|
+
|
|
89
|
+
return _ignore
|
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):
|