Skip to content

Commit f7e0736

Browse files
committed
feat(extensions): select exact catalog releases
Keep current release metadata compatible with existing catalogs while allowing trusted catalogs to publish historical release URLs and digests. Add exact-version CLI selection, archive identity checks, tests, and documentation. Refs #4719; follows up #4712. Signed-off-by: 李永祺 <doribelove@gmail.com>
1 parent 25d43a9 commit f7e0736

6 files changed

Lines changed: 621 additions & 8 deletions

File tree

‎docs/reference/extensions.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,19 @@ specify extension add <name>
2626
| --------------- | -------------------------------------------------------- |
2727
| `--dev` | Install from a local directory (for development) |
2828
| `--from <url>` | Install from a custom URL instead of the catalog |
29+
| `--version <v>` | Install an exact version advertised by a catalog |
2930
| `--force` | Overwrite if the extension is already installed |
3031
| `--priority <N>`| Resolution priority (default: 10; lower = higher precedence) |
3132

3233
Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration.
3334

35+
An unqualified catalog install still selects the advertised current version.
36+
`--version` uses only the winning catalog source for that extension ID; it does
37+
not fall back to a lower-priority source when the requested version is absent.
38+
Discovery-only catalogs remain non-installable. `--version` cannot be combined
39+
with `--dev` or the direct-URL `--from` option. The downloaded archive's extension
40+
ID and version are checked before installation.
41+
3442
> **Note:** All extension commands require a project already initialized with `specify init`.
3543
3644
## Remove an Extension
@@ -79,9 +87,43 @@ including for help, the existing human-readable behavior is unchanged.
7987

8088
```bash
8189
specify extension info <name>
90+
specify extension info <name> --versions
8291
```
8392

8493
Shows detailed information about an installed or available extension, including its description, version, commands, and configuration.
94+
`--versions` lists the current and historical versions advertised by the
95+
winning catalog source; it labels discovery-only sources as non-installable.
96+
97+
Catalogs may keep the current release in the existing top-level fields and add
98+
historical releases in a `releases` mapping. Older single-version catalogs
99+
continue to work unchanged. Each historical release needs its own download URL
100+
and SHA-256 digest; release-specific requirements or provided capabilities must
101+
be placed in that release's record rather than inherited from the current one.
102+
103+
```json
104+
{
105+
"extensions": {
106+
"my-extension": {
107+
"name": "My Extension",
108+
"version": "0.5.1",
109+
"download_url": "https://example.com/my-extension-0.5.1.zip",
110+
"sha256": "<64-character SHA-256 for 0.5.1>",
111+
"releases": {
112+
"0.4.12": {
113+
"download_url": "https://example.com/my-extension-0.4.12.zip",
114+
"sha256": "<64-character SHA-256 for 0.4.12>"
115+
}
116+
}
117+
}
118+
}
119+
}
120+
```
121+
122+
The example omits other catalog metadata for brevity. The current version must
123+
not be repeated in `releases`; malformed or duplicate release records are
124+
rejected. Bundle pins still use the current catalog resolution path until the
125+
separate bundle work described in [#4719](https://github.com/github/spec-kit/issues/4719)
126+
adds exact-version component lookup.
85127

86128
## Update Extensions
87129

‎src/specify_cli/extensions/__init__.py‎

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2722,6 +2722,8 @@ def install_from_archive(
27222722
source_name: str | None = None,
27232723
content_type: str | None = None,
27242724
catalog_name: str | None = None,
2725+
expected_id: str | None = None,
2726+
expected_version: str | None = None,
27252727
) -> ExtensionManifest:
27262728
"""Install an extension from a supported archive.
27272729
@@ -2771,6 +2773,23 @@ def install_from_archive(
27712773
if not manifest_path.exists():
27722774
raise ValidationError("No extension.yml found in archive")
27732775

2776+
if expected_id is not None or expected_version is not None:
2777+
archive_manifest = ExtensionManifest(manifest_path)
2778+
if expected_id is not None and archive_manifest.id != expected_id:
2779+
raise ValidationError(
2780+
f"Downloaded extension declares ID '{archive_manifest.id}', "
2781+
f"expected '{expected_id}'."
2782+
)
2783+
if (
2784+
expected_version is not None
2785+
and pkg_version.Version(archive_manifest.version)
2786+
!= pkg_version.Version(expected_version)
2787+
):
2788+
raise ValidationError(
2789+
f"Downloaded extension '{archive_manifest.id}' declares version "
2790+
f"{archive_manifest.version}, expected {expected_version}."
2791+
)
2792+
27742793
# Install from extracted directory
27752794
return self.install_from_directory(
27762795
extension_dir,
@@ -2934,6 +2953,8 @@ def install_from_zip(
29342953
source_name: str | None = None,
29352954
content_type: str | None = None,
29362955
catalog_name: str | None = None,
2956+
expected_id: str | None = None,
2957+
expected_version: str | None = None,
29372958
) -> ExtensionManifest:
29382959
"""Backward-compatible wrapper for archive installation."""
29392960
return self.install_from_archive(
@@ -2945,6 +2966,8 @@ def install_from_zip(
29452966
source_name=source_name,
29462967
content_type=content_type,
29472968
catalog_name=catalog_name,
2969+
expected_id=expected_id,
2970+
expected_version=expected_version,
29482971
)
29492972

29502973
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
@@ -4336,13 +4359,16 @@ def search(
43364359

43374360
return results
43384361

4339-
def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]:
4362+
def get_extension_info(
4363+
self, extension_id: str, version: str | None = None
4364+
) -> Optional[Dict[str, Any]]:
43404365
"""Get detailed information about a specific extension.
43414366
43424367
Searches all active catalogs in priority order.
43434368
43444369
Args:
43454370
extension_id: ID of the extension
4371+
version: Exact catalog version, or ``None`` for the advertised current release
43464372
43474373
Returns:
43484374
Extension metadata (annotated with ``_catalog_name`` and
@@ -4351,9 +4377,20 @@ def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]:
43514377
all_extensions = self._get_merged_extensions()
43524378
for ext_data in all_extensions:
43534379
if ext_data["id"] == extension_id:
4354-
return ext_data
4380+
from ._catalog_versions import select_release
4381+
4382+
return select_release(ext_data, version)
43554383
return None
43564384

4385+
def get_extension_versions(self, extension_id: str) -> list[str]:
4386+
"""List versions advertised by the winning catalog source."""
4387+
from ._catalog_versions import available_versions
4388+
4389+
for ext_data in self._get_merged_extensions():
4390+
if ext_data["id"] == extension_id:
4391+
return available_versions(ext_data)
4392+
return []
4393+
43574394
def download_extension(
43584395
self, extension_id: str, target_dir: Optional[Path] = None
43594396
) -> Path:
@@ -4369,13 +4406,30 @@ def download_extension(
43694406
Raises:
43704407
ExtensionError: If extension not found or download fails
43714408
"""
4372-
import urllib.error
4373-
43744409
# Get extension info from catalog
43754410
ext_info = self.get_extension_info(extension_id)
43764411
if not ext_info:
43774412
raise ExtensionError(f"Extension '{extension_id}' not found in catalog")
43784413

4414+
return self.download_extension_info(ext_info, target_dir=target_dir)
4415+
4416+
def download_extension_info(
4417+
self, ext_info: Dict[str, Any], target_dir: Optional[Path] = None
4418+
) -> Path:
4419+
"""Download a selected release without looking up its ID again.
4420+
4421+
Exact-version callers pass the already-selected record so a catalog
4422+
change between lookup and download cannot substitute the current URL.
4423+
"""
4424+
import urllib.error
4425+
4426+
extension_id = ext_info["id"]
4427+
if not ext_info.get("_install_allowed", True):
4428+
raise ExtensionError(
4429+
f"Extension '{extension_id}' is from a discovery-only catalog; "
4430+
"installation is not allowed."
4431+
)
4432+
43794433
# Bundled extensions without a download URL must be installed locally
43804434
if ext_info.get("bundled") and not ext_info.get("download_url"):
43814435
raise ExtensionError(
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Exact-version selection for extension catalog entries.
2+
3+
Legacy entries advertise one release at the top level. A versioned entry keeps
4+
that current release unchanged for older clients and adds historical releases
5+
under ``releases``. Historical records must carry their own URL and digest; a
6+
new current release must never supply either for an older version by accident.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import re
12+
from typing import Any
13+
14+
from packaging.version import InvalidVersion, Version
15+
16+
from . import ExtensionError
17+
18+
_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
19+
_CURRENT_ONLY = frozenset(
20+
{
21+
"version",
22+
"download_url",
23+
"sha256",
24+
"requires",
25+
"provides",
26+
"bundled",
27+
"verified",
28+
"releases",
29+
}
30+
)
31+
32+
33+
def _validated_releases(entry: dict[str, Any]) -> dict[str, dict[str, Any]]:
34+
"""Return a checked history, or reject an ambiguous catalog entry."""
35+
if "releases" not in entry:
36+
return {}
37+
releases = entry["releases"]
38+
extension_id = entry.get("id", "<unknown>")
39+
if not isinstance(releases, dict):
40+
raise ExtensionError(
41+
f"Extension '{extension_id}' has an invalid releases mapping."
42+
)
43+
44+
current = entry.get("version")
45+
if not isinstance(current, str) or not current.strip():
46+
raise ExtensionError(
47+
f"Extension '{extension_id}' has releases but no current version."
48+
)
49+
try:
50+
normalized_current = Version(current)
51+
except InvalidVersion:
52+
raise ExtensionError(
53+
f"Extension '{extension_id}' has an invalid current version '{current}'."
54+
) from None
55+
56+
seen = {normalized_current}
57+
for release_version, record in releases.items():
58+
if not isinstance(release_version, str) or not release_version.strip():
59+
raise ExtensionError(
60+
f"Extension '{extension_id}' has an invalid release version key."
61+
)
62+
try:
63+
normalized = Version(release_version)
64+
except InvalidVersion:
65+
raise ExtensionError(
66+
f"Extension '{extension_id}' has invalid release version '{release_version}'."
67+
) from None
68+
if normalized in seen:
69+
raise ExtensionError(
70+
f"Extension '{extension_id}' repeats release version '{release_version}'."
71+
)
72+
seen.add(normalized)
73+
if not isinstance(record, dict):
74+
raise ExtensionError(
75+
f"Extension '{extension_id}' release '{release_version}' must be an object."
76+
)
77+
if any(
78+
key in record
79+
for key in (
80+
"id",
81+
"version",
82+
"releases",
83+
"_catalog_name",
84+
"_install_allowed",
85+
)
86+
):
87+
raise ExtensionError(
88+
f"Extension '{extension_id}' release '{release_version}' contains reserved fields."
89+
)
90+
if (
91+
not isinstance(record.get("download_url"), str)
92+
or not record["download_url"].strip()
93+
):
94+
raise ExtensionError(
95+
f"Extension '{extension_id}' release '{release_version}' needs a download_url."
96+
)
97+
if not isinstance(record.get("sha256"), str) or not _SHA256.fullmatch(
98+
record["sha256"]
99+
):
100+
raise ExtensionError(
101+
f"Extension '{extension_id}' release '{release_version}' needs a SHA-256 digest."
102+
)
103+
for field in ("requires", "provides"):
104+
if field in record and not isinstance(record[field], dict):
105+
raise ExtensionError(
106+
f"Extension '{extension_id}' release '{release_version}' has invalid {field}."
107+
)
108+
109+
return releases
110+
111+
112+
def select_release(entry: dict[str, Any], version: str | None) -> dict[str, Any] | None:
113+
"""Select from the winning catalog entry without consulting lower sources.
114+
115+
``None`` is returned when the requested version is absent. Callers can then
116+
report a missing historical release without falling through to another
117+
catalog or silently substituting the current release.
118+
"""
119+
releases = _validated_releases(entry)
120+
if version is None or version == entry.get("version"):
121+
return entry
122+
record = releases.get(version)
123+
if record is None:
124+
return None
125+
common = {key: value for key, value in entry.items() if key not in _CURRENT_ONLY}
126+
return {**common, **record, "version": version}
127+
128+
129+
def available_versions(entry: dict[str, Any]) -> list[str]:
130+
"""Current version first, then historical versions in descending order."""
131+
releases = _validated_releases(entry)
132+
current = entry.get("version")
133+
if not isinstance(current, str) or not current:
134+
return []
135+
return [current, *sorted(releases, key=Version, reverse=True)]

0 commit comments

Comments
 (0)