@@ -3458,6 +3458,87 @@ def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data):
34583458class 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\n name: Test Extension\n version: 1.0.0\n commands: []\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