Skip to content

Commit 2d3ab29

Browse files
authored
fix: scaffold extension config when installing via bundler (#4285)
* fix: scaffold extension config when installing via bundler ExtensionPrimitive.install_from_directory/install_from_zip in the bundler never called ExtensionManager.scaffold_config, so an extension's provides.config templates were deployed only when installed with `specify extension add`, not via `specify bundle install`. Call scaffold_config after a successful install in both code paths, matching the extension-add command flow. Fixes #4283 * test: cover catalog/ZIP extension install scaffolding config Adds a regression test that exercises the catalog-download branch (install_from_zip) rather than the bundled-directory branch, per review feedback that the reported reproduction used a catalog-resolved extension.
1 parent b9c9403 commit 2d3ab29

2 files changed

Lines changed: 111 additions & 14 deletions

File tree

src/specify_cli/bundler/services/primitives.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,10 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None:
263263
component.version,
264264
_bundled_manifest_version(bundled / "extension.yml", "extension"),
265265
)
266-
self._manager.install_from_directory(
266+
manifest = self._manager.install_from_directory(
267267
bundled, speckit_version, priority=priority, force=force
268268
)
269+
self._manager.scaffold_config(manifest.id)
269270
return
270271

271272
if not self._allow_network:
@@ -293,9 +294,10 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None:
293294
)
294295
zip_path = catalog.download_extension(component.id)
295296
try:
296-
self._manager.install_from_zip(
297+
manifest = self._manager.install_from_zip(
297298
zip_path, speckit_version, priority=priority, force=force
298299
)
300+
self._manager.scaffold_config(manifest.id)
299301
finally:
300302
with contextlib.suppress(Exception):
301303
if zip_path.exists():

tests/unit/test_bundler_primitives.py

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
from pathlib import Path
10+
from types import SimpleNamespace
1011

1112
import 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("---\ndescription: Test\n---\n\nhi\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+
188278
def 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

Comments
 (0)