mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-06T00:00:00Z",
|
||||
"updated_at": "2026-04-10T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
|
||||
"extensions": {
|
||||
"git": {
|
||||
@@ -10,27 +10,13 @@
|
||||
"description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip",
|
||||
"bundled": true,
|
||||
"tags": [
|
||||
"git",
|
||||
"branching",
|
||||
"workflow",
|
||||
"core"
|
||||
]
|
||||
},
|
||||
"selftest": {
|
||||
"name": "Spec Kit Self-Test Utility",
|
||||
"id": "selftest",
|
||||
"version": "1.0.0",
|
||||
"description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip",
|
||||
"tags": [
|
||||
"testing",
|
||||
"core",
|
||||
"utility"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3007,7 +3007,7 @@ def extension_add(
|
||||
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
||||
):
|
||||
"""Install an extension."""
|
||||
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
|
||||
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
@@ -3109,6 +3109,19 @@ def extension_add(
|
||||
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
|
||||
|
||||
if bundled_path is None:
|
||||
# Bundled extensions without a download URL must come from the local package
|
||||
if ext_info.get("bundled") and not ext_info.get("download_url"):
|
||||
console.print(
|
||||
f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit "
|
||||
f"but could not be found in the installed package."
|
||||
)
|
||||
console.print(
|
||||
"\nThis usually means the spec-kit installation is incomplete or corrupted."
|
||||
)
|
||||
console.print("Try reinstalling spec-kit:")
|
||||
console.print(f" {REINSTALL_COMMAND}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Enforce install_allowed policy
|
||||
if not ext_info.get("_install_allowed", True):
|
||||
catalog_name = ext_info.get("_catalog_name", "community")
|
||||
|
||||
@@ -38,6 +38,8 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({
|
||||
})
|
||||
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
|
||||
|
||||
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
|
||||
|
||||
|
||||
def _load_core_command_names() -> frozenset[str]:
|
||||
"""Discover bundled core command names from the packaged templates.
|
||||
@@ -1870,6 +1872,14 @@ class ExtensionCatalog:
|
||||
if not ext_info:
|
||||
raise ExtensionError(f"Extension '{extension_id}' not found in catalog")
|
||||
|
||||
# Bundled extensions without a download URL must be installed locally
|
||||
if ext_info.get("bundled") and not ext_info.get("download_url"):
|
||||
raise ExtensionError(
|
||||
f"Extension '{extension_id}' is bundled with spec-kit and has no download URL. "
|
||||
f"It should be installed from the local package. "
|
||||
f"Try reinstalling: {REINSTALL_COMMAND}"
|
||||
)
|
||||
|
||||
download_url = ext_info.get("download_url")
|
||||
if not download_url:
|
||||
raise ExtensionError(f"Extension '{extension_id}' has no download URL")
|
||||
|
||||
@@ -2995,6 +2995,122 @@ class TestExtensionAddCLI:
|
||||
f"but was called with '{download_called_with[0]}'"
|
||||
)
|
||||
|
||||
def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path):
|
||||
"""extension add should give a clear error when a bundled extension is not found locally."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch, MagicMock
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Create project structure
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
(project_dir / ".specify" / "extensions").mkdir(parents=True)
|
||||
|
||||
# Mock catalog that returns a bundled extension without download_url
|
||||
mock_catalog = MagicMock()
|
||||
mock_catalog.get_extension_info.return_value = {
|
||||
"id": "git",
|
||||
"name": "Git Branching Workflow",
|
||||
"version": "1.0.0",
|
||||
"description": "Git branching extension",
|
||||
"bundled": True,
|
||||
"_install_allowed": True,
|
||||
}
|
||||
mock_catalog.search.return_value = []
|
||||
|
||||
with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \
|
||||
patch("specify_cli._locate_bundled_extension", return_value=None), \
|
||||
patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "git"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "bundled with spec-kit" in result.output
|
||||
assert "reinstall" in result.output.lower()
|
||||
|
||||
|
||||
class TestDownloadExtensionBundled:
|
||||
"""Tests for download_extension handling of bundled extensions."""
|
||||
|
||||
def test_download_extension_raises_for_bundled(self, temp_dir):
|
||||
"""download_extension should raise a clear error for bundled extensions without a URL."""
|
||||
from unittest.mock import patch
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
bundled_ext_info = {
|
||||
"name": "Git Branching Workflow",
|
||||
"id": "git",
|
||||
"version": "1.0.0",
|
||||
"description": "Git workflow",
|
||||
"bundled": True,
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info):
|
||||
with pytest.raises(ExtensionError, match="bundled with spec-kit"):
|
||||
catalog.download_extension("git")
|
||||
|
||||
def test_download_extension_allows_bundled_with_url(self, temp_dir):
|
||||
"""download_extension should allow bundled extensions that have a download_url (newer version)."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
import urllib.request
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
bundled_with_url = {
|
||||
"name": "Git Branching Workflow",
|
||||
"id": "git",
|
||||
"version": "2.0.0",
|
||||
"description": "Git workflow",
|
||||
"bundled": True,
|
||||
"download_url": "https://example.com/git-2.0.0.zip",
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = b"fake zip data"
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \
|
||||
patch.object(urllib.request, "urlopen", return_value=mock_response):
|
||||
result = catalog.download_extension("git")
|
||||
assert result.name == "git-2.0.0.zip"
|
||||
|
||||
def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir):
|
||||
"""download_extension should raise 'no download URL' for non-bundled extensions without URL."""
|
||||
from unittest.mock import patch
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
non_bundled_ext_info = {
|
||||
"name": "Some Extension",
|
||||
"id": "some-ext",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info):
|
||||
with pytest.raises(ExtensionError, match="has no download URL"):
|
||||
catalog.download_extension("some-ext")
|
||||
|
||||
|
||||
class TestExtensionUpdateCLI:
|
||||
"""CLI integration tests for extension update command."""
|
||||
|
||||
Reference in New Issue
Block a user