@@ -9115,3 +9115,224 @@ def test_forge_extension_install_listing_hyphenates_command_names(
91159115 # Forge registers hyphenated command names, so the summary must match.
91169116 assert "speckit-test-ext-hello" in result .output
91179117 assert "speckit.test-ext.hello" not in result .output
9118+
9119+
9120+ # ===== Extension Config Scaffolding Tests =====
9121+
9122+
9123+ class TestExtensionConfigScaffolding :
9124+ """Test automatic config scaffolding during add/enable lifecycle."""
9125+
9126+ def _make_extension (self , ext_dir , config_entries = None ):
9127+ """Create a minimal extension with optional config templates."""
9128+ ext_dir .mkdir (parents = True , exist_ok = True )
9129+ manifest = {
9130+ "schema_version" : "1.0" ,
9131+ "extension" : {
9132+ "id" : "test-ext" ,
9133+ "name" : "Test Extension" ,
9134+ "version" : "1.0.0" ,
9135+ "description" : "Test extension" ,
9136+ "author" : "Test" ,
9137+ "repository" : "https://github.com/test/test" ,
9138+ "license" : "MIT" ,
9139+ "homepage" : "https://github.com/test/test" ,
9140+ },
9141+ "requires" : {"speckit_version" : ">=0.1.0" },
9142+ "provides" : {
9143+ "commands" : [{
9144+ "name" : "speckit.test-ext.example" ,
9145+ "file" : "commands/example.md" ,
9146+ "description" : "Example command" ,
9147+ }],
9148+ },
9149+ "tags" : ["test" ],
9150+ }
9151+ if config_entries :
9152+ manifest ["provides" ]["config" ] = config_entries
9153+ import yaml
9154+ (ext_dir / "extension.yml" ).write_text (yaml .dump (manifest , default_flow_style = False ))
9155+ # Create command file so validation passes
9156+ (ext_dir / "commands" ).mkdir (exist_ok = True )
9157+ (ext_dir / "commands" / "example.md" ).write_text ("# Example" )
9158+ return manifest
9159+
9160+ def test_scaffold_config_deploys_template (self , tmp_path ):
9161+ """Config template should be copied to .specify/ on scaffold."""
9162+ from specify_cli .extensions import ExtensionManager
9163+ project = tmp_path / "project"
9164+ specify_dir = project / ".specify"
9165+ specify_dir .mkdir (parents = True )
9166+ ext_dir = specify_dir / "extensions" / "test-ext"
9167+ self ._make_extension (ext_dir , config_entries = [{
9168+ "name" : "test-config.yml" ,
9169+ "template" : "config-template.yml" ,
9170+ "description" : "Test config" ,
9171+ "required" : True ,
9172+ }])
9173+ (ext_dir / "config-template.yml" ).write_text ("setting: default" )
9174+
9175+ manager = ExtensionManager (project )
9176+ deployed , skipped , failed = manager .scaffold_config ("test-ext" )
9177+
9178+ assert deployed == ["test-config.yml" ]
9179+ assert skipped == []
9180+ assert failed == []
9181+ assert (specify_dir / "test-config.yml" ).exists ()
9182+ assert (specify_dir / "test-config.yml" ).read_text () == "setting: default"
9183+
9184+ def test_scaffold_config_preserves_existing (self , tmp_path ):
9185+ """Existing config files should never be overwritten."""
9186+ from specify_cli .extensions import ExtensionManager
9187+ project = tmp_path / "project"
9188+ specify_dir = project / ".specify"
9189+ specify_dir .mkdir (parents = True )
9190+ (specify_dir / "test-config.yml" ).write_text ("setting: custom" )
9191+ ext_dir = specify_dir / "extensions" / "test-ext"
9192+ self ._make_extension (ext_dir , config_entries = [{
9193+ "name" : "test-config.yml" ,
9194+ "template" : "config-template.yml" ,
9195+ "description" : "Test config" ,
9196+ "required" : True ,
9197+ }])
9198+ (ext_dir / "config-template.yml" ).write_text ("setting: default" )
9199+
9200+ manager = ExtensionManager (project )
9201+ deployed , skipped , failed = manager .scaffold_config ("test-ext" )
9202+
9203+ assert deployed == []
9204+ assert skipped == ["test-config.yml" ]
9205+ assert failed == []
9206+ assert (specify_dir / "test-config.yml" ).read_text () == "setting: custom"
9207+
9208+ def test_scaffold_config_no_config_section (self , tmp_path ):
9209+ """Extensions without config section should return empty list."""
9210+ from specify_cli .extensions import ExtensionManager
9211+ project = tmp_path / "project"
9212+ specify_dir = project / ".specify"
9213+ specify_dir .mkdir (parents = True )
9214+ ext_dir = specify_dir / "extensions" / "test-ext"
9215+ self ._make_extension (ext_dir )
9216+
9217+ manager = ExtensionManager (project )
9218+ deployed , skipped , failed = manager .scaffold_config ("test-ext" )
9219+
9220+ assert deployed == []
9221+ assert skipped == []
9222+ assert failed == []
9223+
9224+ def test_scaffold_config_missing_template_file (self , tmp_path ):
9225+ """Missing template files should be reported as failed."""
9226+ from specify_cli .extensions import ExtensionManager
9227+ project = tmp_path / "project"
9228+ specify_dir = project / ".specify"
9229+ specify_dir .mkdir (parents = True )
9230+ ext_dir = specify_dir / "extensions" / "test-ext"
9231+ self ._make_extension (ext_dir , config_entries = [{
9232+ "name" : "test-config.yml" ,
9233+ "template" : "nonexistent.yml" ,
9234+ "description" : "Test config" ,
9235+ }])
9236+
9237+ manager = ExtensionManager (project )
9238+ deployed , skipped , failed = manager .scaffold_config ("test-ext" )
9239+
9240+ assert deployed == []
9241+ assert skipped == []
9242+ assert failed == ["test-config.yml" ]
9243+
9244+ def test_scaffold_config_rejects_path_traversal (self , tmp_path ):
9245+ """Config names with path traversal should be rejected."""
9246+ from specify_cli .extensions import ExtensionManager
9247+ project = tmp_path / "project"
9248+ specify_dir = project / ".specify"
9249+ specify_dir .mkdir (parents = True )
9250+ ext_dir = specify_dir / "extensions" / "test-ext"
9251+ self ._make_extension (ext_dir , config_entries = [
9252+ {"name" : "../etc/passwd" , "template" : "config.yml" },
9253+ {"name" : "safe.yml" , "template" : "../../secrets.yml" },
9254+ {"name" : "/absolute/path.yml" , "template" : "config.yml" },
9255+ ])
9256+ (ext_dir / "config.yml" ).write_text ("safe: true" )
9257+
9258+ manager = ExtensionManager (project )
9259+ deployed , skipped , failed = manager .scaffold_config ("test-ext" )
9260+
9261+ assert deployed == []
9262+ assert skipped == []
9263+ assert failed == ["../etc/passwd" , "safe.yml" , "/absolute/path.yml" ]
9264+
9265+ def test_scaffold_config_rejects_directory_template (self , tmp_path ):
9266+ """Directory templates should be rejected (must be regular files)."""
9267+ from specify_cli .extensions import ExtensionManager
9268+ project = tmp_path / "project"
9269+ specify_dir = project / ".specify"
9270+ specify_dir .mkdir (parents = True )
9271+ ext_dir = specify_dir / "extensions" / "test-ext"
9272+ self ._make_extension (ext_dir , config_entries = [{
9273+ "name" : "test-config.yml" ,
9274+ "template" : "config-dir" ,
9275+ }])
9276+ (ext_dir / "config-dir" ).mkdir ()
9277+
9278+ manager = ExtensionManager (project )
9279+ deployed , skipped , failed = manager .scaffold_config ("test-ext" )
9280+
9281+ assert deployed == []
9282+ assert skipped == []
9283+ assert failed == ["test-config.yml" ]
9284+
9285+ def test_scaffold_config_rejects_symlink_template (self , tmp_path ):
9286+ """Symlink templates should not be copied."""
9287+ from specify_cli .extensions import ExtensionManager
9288+ project = tmp_path / "project"
9289+ specify_dir = project / ".specify"
9290+ specify_dir .mkdir (parents = True )
9291+ ext_dir = specify_dir / "extensions" / "test-ext"
9292+ self ._make_extension (ext_dir , config_entries = [{
9293+ "name" : "test-config.yml" ,
9294+ "template" : "config-link.yml" ,
9295+ }])
9296+ real_template = ext_dir / "config-template.yml"
9297+ real_template .write_text ("setting: default" )
9298+ (ext_dir / "config-link.yml" ).symlink_to (real_template )
9299+
9300+ manager = ExtensionManager (project )
9301+ deployed , skipped , failed = manager .scaffold_config ("test-ext" )
9302+
9303+ assert deployed == []
9304+ assert skipped == []
9305+ assert failed == ["test-config.yml" ]
9306+ assert not (specify_dir / "test-config.yml" ).exists ()
9307+
9308+ def test_scaffold_config_malformed_manifest (self , tmp_path ):
9309+ """Malformed config sections should not crash."""
9310+ from specify_cli .extensions import ExtensionManager , ExtensionManifest
9311+ import yaml
9312+ project = tmp_path / "project"
9313+ specify_dir = project / ".specify"
9314+ specify_dir .mkdir (parents = True )
9315+ ext_dir = specify_dir / "extensions" / "test-ext"
9316+ manifest_data = self ._make_extension (ext_dir )
9317+ manifest_data ["provides" ]["config" ] = "not-a-list"
9318+ (ext_dir / "extension.yml" ).write_text (yaml .dump (manifest_data ))
9319+
9320+ manifest = ExtensionManifest (ext_dir / "extension.yml" )
9321+ assert manifest .config == []
9322+
9323+ manager = ExtensionManager (project )
9324+ deployed , skipped , failed = manager .scaffold_config ("test-ext" )
9325+
9326+ assert deployed == []
9327+ assert skipped == []
9328+ assert failed == ["provides.config" ]
9329+
9330+ def test_scaffold_config_missing_manifest_returns_consistent_result (self , tmp_path ):
9331+ """A missing extension manifest should return the documented tuple."""
9332+ from specify_cli .extensions import ExtensionManager
9333+ project = tmp_path / "project"
9334+ (project / ".specify" ).mkdir (parents = True )
9335+
9336+ manager = ExtensionManager (project )
9337+
9338+ assert manager .scaffold_config ("missing" ) == ([], [], [])
0 commit comments