Skip to content

Commit 244e752

Browse files
yxsheeclaude
andauthored
fix(extensions): scaffold new config templates on extension update (#5011)
`extension add` and `extension enable` deploy the templates an extension declares in `provides.config`, and bundle install and refresh do the same (#4285), but `extension update` never called scaffold_config. When a new version introduces a config template, the updated project has no `<ext>-config.yml` while a fresh install of that version does, and `extension enable` cannot deploy it either: it stops early because the extension is already enabled. Scaffold once the install has restored the project's existing configs, so those are skipped exactly as before, and report deployed and failed templates under the update line, as `extension add` does. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 62f6001 commit 244e752

2 files changed

Lines changed: 105 additions & 0 deletions

File tree

‎src/specify_cli/extensions/_command_update_transaction.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,10 @@ def backup_extension_skills(skill_names, *, skills_dir=None):
564564
if cfg_file.is_file():
565565
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
566566

567+
# Scaffold config templates the new version declares, as
568+
# `extension add` does; the configs restored above are kept.
569+
scaffolded, _, unscaffolded = manager.scaffold_config(extension_id)
570+
567571
# 9. Restore metadata from backup (installed_at, enabled state)
568572
if backup_registry_entry and isinstance(backup_registry_entry, dict):
569573
init_options = _commands.load_init_options(project_root)
@@ -630,6 +634,17 @@ def backup_extension_skills(skill_names, *, skills_dir=None):
630634
console.print(
631635
f" [green]✓[/green] Updated to v{update.available}"
632636
)
637+
config_home = f".specify/extensions/{_escape_markup(extension_id)}"
638+
for cfg in scaffolded:
639+
console.print(
640+
f" Config scaffolded: {config_home}/{_escape_markup(cfg)}"
641+
)
642+
if unscaffolded:
643+
console.print(
644+
" [yellow]Warning:[/yellow] Config templates not scaffolded: "
645+
f"{_escape_markup(', '.join(unscaffolded))}. "
646+
"Verify the extension manifest and template files."
647+
)
633648
if cleanup_error is not None:
634649
console.print(
635650
" [yellow]Warning:[/yellow] Could not fully remove "

‎tests/specify_cli/extensions/test_command_update.py‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,96 @@ def test_update_installs_bundled_extension_from_local_copy(self, tmp_path):
422422
assert "Updated to v2.0.0" in flat
423423
assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "2.0.0"
424424

425+
@staticmethod
426+
def _declare_config(ext_dir: Path, template_text: str) -> None:
427+
"""Make *ext_dir* declare ``test-ext-config.yml`` in ``provides.config``."""
428+
manifest_path = ext_dir / "extension.yml"
429+
manifest = yaml.safe_load(manifest_path.read_text())
430+
manifest["provides"]["config"] = [
431+
{"name": "test-ext-config.yml", "template": "config-template.yml"}
432+
]
433+
manifest_path.write_text(yaml.dump(manifest, sort_keys=False))
434+
(ext_dir / "config-template.yml").write_text(template_text)
435+
436+
def _update_from_archive(self, project_dir: Path, source_dir: Path, version: str):
437+
"""Run ``extension update`` with *source_dir* packaged as the catalog archive."""
438+
archive = Path(shutil.make_archive(str(source_dir), "zip", source_dir))
439+
with patch.object(Path, "cwd", return_value=project_dir), \
440+
patch.object(ExtensionCatalog, "get_extension_info", return_value={
441+
"id": "test-ext",
442+
"name": "Test Extension",
443+
"version": version,
444+
"_install_allowed": True,
445+
}), \
446+
patch.object(ExtensionCatalog, "download_extension", return_value=archive):
447+
return CliRunner().invoke(
448+
app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True
449+
)
450+
451+
def test_update_scaffolds_config_declared_by_new_version(self, tmp_path):
452+
"""A config template introduced by the new version is scaffolded, as
453+
``extension add`` would scaffold it for a fresh install."""
454+
project_dir = tmp_path / "project"
455+
(project_dir / ".specify").mkdir(parents=True)
456+
ExtensionManager(project_dir).install_from_directory(
457+
self._create_extension_source(tmp_path, "1.0.0"), "0.1.0"
458+
)
459+
v2_dir = self._create_extension_source(tmp_path, "2.0.0")
460+
self._declare_config(v2_dir, "setting: default\n")
461+
462+
result = self._update_from_archive(project_dir, v2_dir, "2.0.0")
463+
464+
flat = " ".join(result.output.split())
465+
assert result.exit_code == 0, result.output
466+
assert "Updated to v2.0.0" in flat
467+
config = project_dir / ".specify" / "extensions" / "test-ext" / "test-ext-config.yml"
468+
assert config.is_file()
469+
assert config.read_text() == "setting: default\n"
470+
assert "Config scaffolded: .specify/extensions/test-ext/test-ext-config.yml" in flat
471+
472+
def test_update_keeps_existing_config_over_new_template(self, tmp_path):
473+
"""Scaffolding during update never replaces a config the project has."""
474+
project_dir = tmp_path / "project"
475+
(project_dir / ".specify").mkdir(parents=True)
476+
v1_dir = self._create_extension_source(tmp_path, "1.0.0")
477+
self._declare_config(v1_dir, "setting: default\n")
478+
manager = ExtensionManager(project_dir)
479+
manager.install_from_directory(v1_dir, "0.1.0")
480+
assert manager.scaffold_config("test-ext")[0] == ["test-ext-config.yml"]
481+
config = project_dir / ".specify" / "extensions" / "test-ext" / "test-ext-config.yml"
482+
config.write_text("setting: customized\n")
483+
v2_dir = self._create_extension_source(tmp_path, "2.0.0")
484+
self._declare_config(v2_dir, "setting: new-default\n")
485+
486+
result = self._update_from_archive(project_dir, v2_dir, "2.0.0")
487+
488+
flat = " ".join(result.output.split())
489+
assert result.exit_code == 0, result.output
490+
assert "Updated to v2.0.0" in flat
491+
assert config.read_text() == "setting: customized\n"
492+
assert "Config scaffolded" not in flat
493+
494+
def test_update_warns_when_declared_config_cannot_be_scaffolded(self, tmp_path):
495+
"""A declared template missing from the package is reported, not fatal."""
496+
project_dir = tmp_path / "project"
497+
(project_dir / ".specify").mkdir(parents=True)
498+
ExtensionManager(project_dir).install_from_directory(
499+
self._create_extension_source(tmp_path, "1.0.0"), "0.1.0"
500+
)
501+
v2_dir = self._create_extension_source(tmp_path, "2.0.0")
502+
self._declare_config(v2_dir, "setting: default\n")
503+
(v2_dir / "config-template.yml").unlink()
504+
505+
result = self._update_from_archive(project_dir, v2_dir, "2.0.0")
506+
507+
flat = " ".join(result.output.split())
508+
assert result.exit_code == 0, result.output
509+
assert "Updated to v2.0.0" in flat
510+
assert "Config templates not scaffolded: test-ext-config.yml" in flat
511+
assert not (
512+
project_dir / ".specify" / "extensions" / "test-ext" / "test-ext-config.yml"
513+
).exists()
514+
425515
def test_update_bundled_blocked_when_local_copy_lags_catalog(self, tmp_path):
426516
"""When the catalog advertises a newer version than the running release
427517
bundles, the update is reported as requiring a spec-kit upgrade instead

0 commit comments

Comments
 (0)