Skip to content

Commit 50bcf63

Browse files
authored
Fix extension add --from review-thread issues
1 parent c2e4f81 commit 50bcf63

3 files changed

Lines changed: 97 additions & 13 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3604,6 +3604,9 @@ def extension_add(
36043604
if priority < 1:
36053605
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
36063606
raise typer.Exit(1)
3607+
if dev and from_url:
3608+
console.print("[red]Error:[/red] --dev and --from cannot be used together.")
3609+
raise typer.Exit(1)
36073610

36083611
manager = ExtensionManager(project_root)
36093612
speckit_version = get_speckit_version()
@@ -3625,7 +3628,6 @@ def extension_add(
36253628

36263629
elif from_url:
36273630
# Install from URL (ZIP file)
3628-
import urllib.request
36293631
import urllib.error
36303632
from urllib.parse import urlparse
36313633

@@ -3643,27 +3645,17 @@ def extension_add(
36433645
console.print("Only install extensions from sources you trust.\n")
36443646
console.print(f"Downloading from {from_url}...")
36453647

3646-
# Download ZIP to temp location
3647-
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
3648-
download_dir.mkdir(parents=True, exist_ok=True)
3649-
zip_path = download_dir / f"{extension}-url-download.zip"
3650-
36513648
try:
36523649
from specify_cli.authentication.http import open_url as _open_url
36533650

36543651
with _open_url(from_url, timeout=60) as response:
36553652
zip_data = response.read()
3656-
zip_path.write_bytes(zip_data)
36573653

36583654
# Install from downloaded ZIP
3659-
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
3655+
manifest = manager.install_from_zip_bytes(zip_data, speckit_version, priority=priority)
36603656
except urllib.error.URLError as e:
36613657
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
36623658
raise typer.Exit(1)
3663-
finally:
3664-
# Clean up downloaded ZIP
3665-
if zip_path.exists():
3666-
zip_path.unlink()
36673659

36683660
else:
36693661
# Try bundled extensions first (shipped with spec-kit)

src/specify_cli/extensions.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import json
1010
import hashlib
11+
import io
1112
import os
1213
import tempfile
1314
import zipfile
@@ -1224,6 +1225,16 @@ def install_from_zip(
12241225
ValidationError: If manifest is invalid or priority is invalid
12251226
CompatibilityError: If extension is incompatible
12261227
"""
1228+
with zip_path.open("rb") as zip_file:
1229+
return self.install_from_zip_bytes(zip_file.read(), speckit_version, priority=priority)
1230+
1231+
def install_from_zip_bytes(
1232+
self,
1233+
zip_bytes: bytes,
1234+
speckit_version: str,
1235+
priority: int = 10,
1236+
) -> ExtensionManifest:
1237+
"""Install extension from ZIP bytes."""
12271238
# Validate priority early
12281239
if priority < 1:
12291240
raise ValidationError("Priority must be a positive integer (1 or higher)")
@@ -1232,7 +1243,7 @@ def install_from_zip(
12321243
temp_path = Path(tmpdir)
12331244

12341245
# Extract ZIP safely (prevent Zip Slip attack)
1235-
with zipfile.ZipFile(zip_path, 'r') as zf:
1246+
with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r') as zf:
12361247
# Validate all paths first before extracting anything
12371248
temp_path_resolved = temp_path.resolve()
12381249
for member in zf.namelist():

tests/test_extensions.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3458,6 +3458,87 @@ def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data):
34583458
class TestExtensionAddCLI:
34593459
"""CLI integration tests for extension add command."""
34603460

3461+
def test_add_rejects_dev_and_from_together(self, tmp_path):
3462+
"""extension add should reject mutually exclusive --dev and --from flags."""
3463+
from typer.testing import CliRunner
3464+
from unittest.mock import patch
3465+
from specify_cli import app
3466+
3467+
runner = CliRunner()
3468+
project_dir = tmp_path / "test-project"
3469+
project_dir.mkdir()
3470+
(project_dir / ".specify").mkdir()
3471+
3472+
extension_dir = tmp_path / "extension"
3473+
extension_dir.mkdir()
3474+
(extension_dir / "extension.yml").write_text(
3475+
"id: test-ext\nname: Test Extension\nversion: 1.0.0\ncommands: []\n",
3476+
encoding="utf-8",
3477+
)
3478+
3479+
with patch.object(Path, "cwd", return_value=project_dir):
3480+
result = runner.invoke(
3481+
app,
3482+
["extension", "add", str(extension_dir), "--dev", "--from", "https://example.com/ext.zip"],
3483+
catch_exceptions=True,
3484+
)
3485+
3486+
assert result.exit_code == 1
3487+
assert "--dev and --from cannot be used together" in result.output
3488+
3489+
def test_add_from_url_installs_from_downloaded_bytes(self, tmp_path):
3490+
"""extension add --from should install from in-memory bytes."""
3491+
from types import SimpleNamespace
3492+
from typer.testing import CliRunner
3493+
from unittest.mock import patch
3494+
from specify_cli import app
3495+
from specify_cli.extensions import ExtensionManager
3496+
3497+
runner = CliRunner()
3498+
project_dir = tmp_path / "test-project"
3499+
project_dir.mkdir()
3500+
(project_dir / ".specify").mkdir()
3501+
3502+
fake_manifest = SimpleNamespace(
3503+
id="test-ext",
3504+
name="Test Extension",
3505+
version="1.0.0",
3506+
description="desc",
3507+
warnings=[],
3508+
commands=[],
3509+
)
3510+
zip_payload = b"fake-zip-bytes"
3511+
install_args = {}
3512+
3513+
class _Resp:
3514+
def __enter__(self):
3515+
return self
3516+
3517+
def __exit__(self, *_args):
3518+
return False
3519+
3520+
def read(self):
3521+
return zip_payload
3522+
3523+
def _install_from_zip_bytes(self_obj, payload, _speckit_version, priority=10):
3524+
install_args["payload"] = payload
3525+
install_args["priority"] = priority
3526+
return fake_manifest
3527+
3528+
with patch.object(Path, "cwd", return_value=project_dir), \
3529+
patch("specify_cli.authentication.http.open_url", return_value=_Resp()), \
3530+
patch.object(ExtensionManager, "install_from_zip_bytes", _install_from_zip_bytes), \
3531+
patch.object(ExtensionManager, "install_from_zip", side_effect=AssertionError("legacy path install should not be used")):
3532+
result = runner.invoke(
3533+
app,
3534+
["extension", "add", "../../evil", "--from", "https://example.com/ext.zip"],
3535+
catch_exceptions=True,
3536+
)
3537+
3538+
assert result.exit_code == 0, result.output
3539+
assert install_args["payload"] == zip_payload
3540+
assert install_args["priority"] == 10
3541+
34613542
def test_add_by_display_name_uses_resolved_id_for_download(self, tmp_path):
34623543
"""extension add by display name should use resolved ID for download_extension()."""
34633544
from typer.testing import CliRunner

0 commit comments

Comments
 (0)