77from __future__ import annotations
88
99from pathlib import Path
10+ from types import SimpleNamespace
1011
1112import pytest
1213
@@ -173,10 +174,12 @@ def test_bundled_extension_pin_match_installs(tmp_path: Path, monkeypatch):
173174 bundled = _write_manifest (tmp_path / "ext" , "extension" , "1.0.0" )
174175 monkeypatch .setattr (assets , "_locate_bundled_extension" , lambda cid : bundled )
175176 called : list = []
176- monkeypatch .setattr (
177- ExtensionManager , "install_from_directory" ,
178- lambda self , * a , ** k : called .append (a ),
179- )
177+
178+ def _fake_install (self , * a , ** k ):
179+ called .append (a )
180+ return SimpleNamespace (id = "my-ext" )
181+
182+ monkeypatch .setattr (ExtensionManager , "install_from_directory" , _fake_install )
180183
181184 manager = primitive_manager ("extensions" , tmp_path , allow_network = False )
182185 # matching pin, and unpinned, both install cleanly
@@ -185,6 +188,93 @@ def test_bundled_extension_pin_match_installs(tmp_path: Path, monkeypatch):
185188 assert len (called ) == 2
186189
187190
191+ def _write_extension_with_config (ext_dir : Path ) -> None :
192+ """A minimal, real (unmocked) extension source with a provides.config entry."""
193+ import yaml
194+
195+ ext_dir .mkdir (parents = True , exist_ok = True )
196+ manifest = {
197+ "schema_version" : "1.0" ,
198+ "extension" : {
199+ "id" : "my-ext" ,
200+ "name" : "My Extension" ,
201+ "version" : "1.0.0" ,
202+ "description" : "Test extension" ,
203+ },
204+ "requires" : {"speckit_version" : ">=0.1.0" },
205+ "provides" : {
206+ "commands" : [
207+ {"name" : "speckit.my-ext.hello" , "file" : "commands/hello.md" },
208+ ],
209+ "config" : [
210+ {"name" : "my-ext-config.yml" , "template" : "config-template.yml" },
211+ ],
212+ },
213+ }
214+ (ext_dir / "extension.yml" ).write_text (yaml .dump (manifest ), encoding = "utf-8" )
215+ (ext_dir / "config-template.yml" ).write_text ("setting: default\n " , encoding = "utf-8" )
216+ (ext_dir / "commands" ).mkdir (exist_ok = True )
217+ (ext_dir / "commands" / "hello.md" ).write_text ("---\n description: Test\n ---\n \n hi\n " , encoding = "utf-8" )
218+
219+
220+ def test_bundled_extension_install_scaffolds_config (tmp_path : Path , monkeypatch ):
221+ """A bundle-installed extension must have its provides.config templates
222+ scaffolded, exactly like `specify extension add` does (issue: bundle
223+ install skipped ExtensionManager.scaffold_config)."""
224+ import specify_cli ._assets as assets
225+
226+ project = tmp_path / "project"
227+ ext_source = tmp_path / "ext-source"
228+ _write_extension_with_config (ext_source )
229+ monkeypatch .setattr (assets , "_locate_bundled_extension" , lambda cid : ext_source )
230+
231+ manager = primitive_manager ("extensions" , project , allow_network = False )
232+ manager .install (ComponentRef (kind = "extensions" , id = "my-ext" ))
233+
234+ scaffolded = project / ".specify" / "extensions" / "my-ext" / "my-ext-config.yml"
235+ assert scaffolded .exists ()
236+ assert scaffolded .read_text (encoding = "utf-8" ) == "setting: default\n "
237+
238+
239+ def test_catalog_extension_install_scaffolds_config (tmp_path : Path , monkeypatch ):
240+ """A catalog-resolved (downloaded ZIP) extension install must also
241+ scaffold its provides.config templates, matching the bundled-directory
242+ coverage above. Exercises the reported reproduction, which installed an
243+ extension resolved from the catalog rather than one shipped with Spec Kit."""
244+ import zipfile
245+
246+ import specify_cli ._assets as assets
247+ from specify_cli .extensions import ExtensionCatalog
248+
249+ project = tmp_path / "project"
250+ ext_source = tmp_path / "ext-source"
251+ _write_extension_with_config (ext_source )
252+
253+ zip_path = tmp_path / "my-ext.zip"
254+ with zipfile .ZipFile (zip_path , "w" ) as zf :
255+ for f in ext_source .rglob ("*" ):
256+ if f .is_file ():
257+ zf .write (f , f .relative_to (ext_source ))
258+
259+ # No bundled asset located: forces the catalog/ZIP branch (install_from_zip).
260+ monkeypatch .setattr (assets , "_locate_bundled_extension" , lambda cid : None )
261+ monkeypatch .setattr (
262+ ExtensionCatalog ,
263+ "get_extension_info" ,
264+ lambda self , eid : {"id" : eid , "_install_allowed" : True },
265+ )
266+ monkeypatch .setattr (
267+ ExtensionCatalog , "download_extension" , lambda self , eid : zip_path
268+ )
269+
270+ manager = primitive_manager ("extensions" , project , allow_network = True )
271+ manager .install (ComponentRef (kind = "extensions" , id = "my-ext" ))
272+
273+ scaffolded = project / ".specify" / "extensions" / "my-ext" / "my-ext-config.yml"
274+ assert scaffolded .exists ()
275+ assert scaffolded .read_text (encoding = "utf-8" ) == "setting: default\n "
276+
277+
188278def test_bundled_preset_pin_mismatch_refuses (tmp_path : Path , monkeypatch ):
189279 import specify_cli ._assets as assets
190280 from specify_cli .presets import PresetManager
@@ -231,10 +321,12 @@ def test_extension_refresh_calls_install_with_force(tmp_path: Path, monkeypatch)
231321 bundled = _write_manifest (tmp_path / "ext" , "extension" , "1.0.0" )
232322 monkeypatch .setattr (assets , "_locate_bundled_extension" , lambda cid : bundled )
233323 force_values : list = []
234- monkeypatch .setattr (
235- ExtensionManager , "install_from_directory" ,
236- lambda self , * a , ** k : force_values .append (k .get ("force" , False )),
237- )
324+
325+ def _fake_install (self , * a , ** k ):
326+ force_values .append (k .get ("force" , False ))
327+ return SimpleNamespace (id = "my-ext" )
328+
329+ monkeypatch .setattr (ExtensionManager , "install_from_directory" , _fake_install )
238330
239331 manager = primitive_manager ("extensions" , tmp_path , allow_network = False )
240332 manager .refresh (ComponentRef (kind = "extensions" , id = "my-ext" ))
@@ -269,10 +361,12 @@ def test_default_installer_refresh_dispatches_to_kind_manager(tmp_path: Path, mo
269361 bundled = _write_manifest (tmp_path / "ext" , "extension" , "1.0.0" )
270362 monkeypatch .setattr (assets , "_locate_bundled_extension" , lambda cid : bundled )
271363 force_values : list = []
272- monkeypatch .setattr (
273- ExtensionManager , "install_from_directory" ,
274- lambda self , * a , ** k : force_values .append (k .get ("force" , False )),
275- )
364+
365+ def _fake_install (self , * a , ** k ):
366+ force_values .append (k .get ("force" , False ))
367+ return SimpleNamespace (id = "my-ext" )
368+
369+ monkeypatch .setattr (ExtensionManager , "install_from_directory" , _fake_install )
276370
277371 installer = DefaultPrimitiveInstaller (allow_network = False )
278372 installer .refresh (tmp_path , _component ("extensions" , "my-ext" ))
@@ -294,6 +388,7 @@ def test_refresh_succeeds_and_passes_force_true(tmp_path: Path, monkeypatch):
294388 def _fake_install_from_directory (self , * a , ** k ):
295389 force_seen .append (k .get ("force" , False ))
296390 self .registry .add ("my-ext" , {"version" : "1.0.0" })
391+ return SimpleNamespace (id = "my-ext" )
297392
298393 monkeypatch .setattr (
299394 ExtensionManager , "install_from_directory" , _fake_install_from_directory
0 commit comments