Skip to content

Commit d1b95c2

Browse files
authored
fix: bundled extensions should not have download URLs (#2155)
* fix: bundled extensions should not have download URLs (#2151) - Remove selftest from default catalog (not a published extension) - Replace download_url with 'bundled: true' flag for git extension - Add bundled check in extension add flow with clear error message when bundled extension is missing from installed package - Add bundled check in download_extension() with specific error - Direct users to reinstall via uv with full GitHub URL - Add 3 regression tests for bundled extension handling * refactor: address review - move bundled check up-front, extract reinstall constant - Move bundled check before download_url inspection in download_extension() so bundled extensions can never be downloaded even with a URL present - Extract REINSTALL_COMMAND constant to avoid duplicated install strings * fix: allow bundled extensions with download_url to be updated Bundled extensions should only be blocked from download when they have no download_url. If a newer version is published to the catalog with a URL, users should be able to install it to get bug fixes. Add test for bundled-with-URL download path.
1 parent 8bb08ae commit d1b95c2

File tree

4 files changed

+142
-17
lines changed

4 files changed

+142
-17
lines changed

extensions/catalog.json

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schema_version": "1.0",
3-
"updated_at": "2026-04-06T00:00:00Z",
3+
"updated_at": "2026-04-10T00:00:00Z",
44
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
55
"extensions": {
66
"git": {
@@ -10,27 +10,13 @@
1010
"description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection",
1111
"author": "spec-kit-core",
1212
"repository": "https://github.com/github/spec-kit",
13-
"download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip",
13+
"bundled": true,
1414
"tags": [
1515
"git",
1616
"branching",
1717
"workflow",
1818
"core"
1919
]
20-
},
21-
"selftest": {
22-
"name": "Spec Kit Self-Test Utility",
23-
"id": "selftest",
24-
"version": "1.0.0",
25-
"description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.",
26-
"author": "spec-kit-core",
27-
"repository": "https://github.com/github/spec-kit",
28-
"download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip",
29-
"tags": [
30-
"testing",
31-
"core",
32-
"utility"
33-
]
3420
}
3521
}
3622
}

src/specify_cli/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3007,7 +3007,7 @@ def extension_add(
30073007
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
30083008
):
30093009
"""Install an extension."""
3010-
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
3010+
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND
30113011

30123012
project_root = Path.cwd()
30133013

@@ -3109,6 +3109,19 @@ def extension_add(
31093109
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
31103110

31113111
if bundled_path is None:
3112+
# Bundled extensions without a download URL must come from the local package
3113+
if ext_info.get("bundled") and not ext_info.get("download_url"):
3114+
console.print(
3115+
f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit "
3116+
f"but could not be found in the installed package."
3117+
)
3118+
console.print(
3119+
"\nThis usually means the spec-kit installation is incomplete or corrupted."
3120+
)
3121+
console.print("Try reinstalling spec-kit:")
3122+
console.print(f" {REINSTALL_COMMAND}")
3123+
raise typer.Exit(1)
3124+
31123125
# Enforce install_allowed policy
31133126
if not ext_info.get("_install_allowed", True):
31143127
catalog_name = ext_info.get("_catalog_name", "community")

src/specify_cli/extensions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
})
3939
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
4040

41+
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
42+
4143

4244
def _load_core_command_names() -> frozenset[str]:
4345
"""Discover bundled core command names from the packaged templates.
@@ -1870,6 +1872,14 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non
18701872
if not ext_info:
18711873
raise ExtensionError(f"Extension '{extension_id}' not found in catalog")
18721874

1875+
# Bundled extensions without a download URL must be installed locally
1876+
if ext_info.get("bundled") and not ext_info.get("download_url"):
1877+
raise ExtensionError(
1878+
f"Extension '{extension_id}' is bundled with spec-kit and has no download URL. "
1879+
f"It should be installed from the local package. "
1880+
f"Try reinstalling: {REINSTALL_COMMAND}"
1881+
)
1882+
18731883
download_url = ext_info.get("download_url")
18741884
if not download_url:
18751885
raise ExtensionError(f"Extension '{extension_id}' has no download URL")

tests/test_extensions.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2995,6 +2995,122 @@ def mock_download(extension_id):
29952995
f"but was called with '{download_called_with[0]}'"
29962996
)
29972997

2998+
def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path):
2999+
"""extension add should give a clear error when a bundled extension is not found locally."""
3000+
from typer.testing import CliRunner
3001+
from unittest.mock import patch, MagicMock
3002+
from specify_cli import app
3003+
3004+
runner = CliRunner()
3005+
3006+
# Create project structure
3007+
project_dir = tmp_path / "test-project"
3008+
project_dir.mkdir()
3009+
(project_dir / ".specify").mkdir()
3010+
(project_dir / ".specify" / "extensions").mkdir(parents=True)
3011+
3012+
# Mock catalog that returns a bundled extension without download_url
3013+
mock_catalog = MagicMock()
3014+
mock_catalog.get_extension_info.return_value = {
3015+
"id": "git",
3016+
"name": "Git Branching Workflow",
3017+
"version": "1.0.0",
3018+
"description": "Git branching extension",
3019+
"bundled": True,
3020+
"_install_allowed": True,
3021+
}
3022+
mock_catalog.search.return_value = []
3023+
3024+
with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \
3025+
patch("specify_cli._locate_bundled_extension", return_value=None), \
3026+
patch.object(Path, "cwd", return_value=project_dir):
3027+
result = runner.invoke(
3028+
app,
3029+
["extension", "add", "git"],
3030+
catch_exceptions=True,
3031+
)
3032+
3033+
assert result.exit_code != 0
3034+
assert "bundled with spec-kit" in result.output
3035+
assert "reinstall" in result.output.lower()
3036+
3037+
3038+
class TestDownloadExtensionBundled:
3039+
"""Tests for download_extension handling of bundled extensions."""
3040+
3041+
def test_download_extension_raises_for_bundled(self, temp_dir):
3042+
"""download_extension should raise a clear error for bundled extensions without a URL."""
3043+
from unittest.mock import patch
3044+
3045+
project_dir = temp_dir / "project"
3046+
project_dir.mkdir()
3047+
(project_dir / ".specify").mkdir()
3048+
3049+
catalog = ExtensionCatalog(project_dir)
3050+
3051+
bundled_ext_info = {
3052+
"name": "Git Branching Workflow",
3053+
"id": "git",
3054+
"version": "1.0.0",
3055+
"description": "Git workflow",
3056+
"bundled": True,
3057+
}
3058+
3059+
with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info):
3060+
with pytest.raises(ExtensionError, match="bundled with spec-kit"):
3061+
catalog.download_extension("git")
3062+
3063+
def test_download_extension_allows_bundled_with_url(self, temp_dir):
3064+
"""download_extension should allow bundled extensions that have a download_url (newer version)."""
3065+
from unittest.mock import patch, MagicMock
3066+
import urllib.request
3067+
3068+
project_dir = temp_dir / "project"
3069+
project_dir.mkdir()
3070+
(project_dir / ".specify").mkdir()
3071+
3072+
catalog = ExtensionCatalog(project_dir)
3073+
3074+
bundled_with_url = {
3075+
"name": "Git Branching Workflow",
3076+
"id": "git",
3077+
"version": "2.0.0",
3078+
"description": "Git workflow",
3079+
"bundled": True,
3080+
"download_url": "https://example.com/git-2.0.0.zip",
3081+
}
3082+
3083+
mock_response = MagicMock()
3084+
mock_response.read.return_value = b"fake zip data"
3085+
mock_response.__enter__ = lambda s: s
3086+
mock_response.__exit__ = MagicMock(return_value=False)
3087+
3088+
with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \
3089+
patch.object(urllib.request, "urlopen", return_value=mock_response):
3090+
result = catalog.download_extension("git")
3091+
assert result.name == "git-2.0.0.zip"
3092+
3093+
def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir):
3094+
"""download_extension should raise 'no download URL' for non-bundled extensions without URL."""
3095+
from unittest.mock import patch
3096+
3097+
project_dir = temp_dir / "project"
3098+
project_dir.mkdir()
3099+
(project_dir / ".specify").mkdir()
3100+
3101+
catalog = ExtensionCatalog(project_dir)
3102+
3103+
non_bundled_ext_info = {
3104+
"name": "Some Extension",
3105+
"id": "some-ext",
3106+
"version": "1.0.0",
3107+
"description": "Test",
3108+
}
3109+
3110+
with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info):
3111+
with pytest.raises(ExtensionError, match="has no download URL"):
3112+
catalog.download_extension("some-ext")
3113+
29983114

29993115
class TestExtensionUpdateCLI:
30003116
"""CLI integration tests for extension update command."""

0 commit comments

Comments
 (0)