Skip to content

Commit 2197746

Browse files
committed
feat(extensions): scaffold config templates on extension add/enable
Deploy an extension's provides.config templates into .specify/ when the extension is added or enabled. Existing files are never overwritten, so user customizations are preserved. Addresses the review on #2000: - ExtensionManifest.config returns [] unless provides.config is a list of dicts, so a malformed manifest cannot crash callers. - scaffold_config returns a consistent (deployed, skipped_existing, failed) tuple on every path, including a missing manifest. - Template paths must resolve inside the extension dir and targets inside .specify/; symlinks and non-regular files are rejected. - Callers distinguish "already exists (preserved)" from "not scaffolded", and extension_enable no longer crashes on a corrupt manifest. - Tests cover traversal, absolute paths, symlinks, directory templates, malformed provides.config, and the missing-manifest tuple shape. Ported onto the extensions package introduced by #3014: the manager and manifest changes land in extensions/__init__.py and the CLI wiring in extensions/_commands.py.
1 parent c0fe0e4 commit 2197746

3 files changed

Lines changed: 345 additions & 0 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,14 @@ def commands(self) -> List[Dict[str, Any]]:
490490
"""Get list of provided commands."""
491491
return self.data.get("provides", {}).get("commands", [])
492492

493+
@property
494+
def config(self) -> List[Dict[str, Any]]:
495+
"""Get list of provided config templates, normalized to dictionaries."""
496+
raw = self.data.get("provides", {}).get("config", [])
497+
if not isinstance(raw, list) or not all(isinstance(entry, dict) for entry in raw):
498+
return []
499+
return raw
500+
493501
@property
494502
def hooks(self) -> Dict[str, Any]:
495503
"""Get hook definitions."""
@@ -2088,6 +2096,84 @@ def install_from_zip(
20882096
extension_dir, speckit_version, priority=priority, force=force
20892097
)
20902098

2099+
def scaffold_config(self, extension_id: str) -> tuple[List[str], List[str], List[str]]:
2100+
"""Deploy config templates from an installed extension to the project.
2101+
2102+
Reads the extension's manifest provides.config section and copies
2103+
each config template to the project's .specify/ directory. Existing
2104+
config files are never overwritten (user customizations are preserved).
2105+
2106+
Args:
2107+
extension_id: ID of the installed extension
2108+
2109+
Returns:
2110+
Tuple of (deployed, skipped_existing, failed) where each is a list
2111+
of config file names.
2112+
"""
2113+
ext_dir = self.extensions_dir / extension_id
2114+
manifest_path = ext_dir / "extension.yml"
2115+
if not manifest_path.exists():
2116+
return [], [], []
2117+
2118+
manifest = ExtensionManifest(manifest_path)
2119+
deployed = []
2120+
skipped_existing = []
2121+
failed = []
2122+
2123+
provides = manifest.data.get("provides", {})
2124+
raw_config = provides.get("config", [])
2125+
config_is_malformed = (
2126+
"config" in provides
2127+
and (
2128+
not isinstance(raw_config, list)
2129+
or not all(isinstance(entry, dict) for entry in raw_config)
2130+
)
2131+
)
2132+
if config_is_malformed:
2133+
return deployed, skipped_existing, ["provides.config"]
2134+
2135+
ext_dir_resolved = ext_dir.resolve()
2136+
specify_dir_resolved = (self.project_root / ".specify").resolve()
2137+
2138+
for config_entry in manifest.config:
2139+
template_name = config_entry.get("template", "")
2140+
target_name = config_entry.get("name", template_name)
2141+
failure_name = target_name if isinstance(target_name, str) and target_name else "provides.config"
2142+
if not isinstance(template_name, str) or not template_name:
2143+
failed.append(failure_name)
2144+
continue
2145+
if not isinstance(target_name, str) or not target_name:
2146+
failed.append(failure_name)
2147+
continue
2148+
2149+
template_candidate = ext_dir / template_name
2150+
template_path = template_candidate.resolve()
2151+
target_path = (self.project_root / ".specify" / target_name).resolve()
2152+
try:
2153+
template_path.relative_to(ext_dir_resolved)
2154+
target_path.relative_to(specify_dir_resolved)
2155+
except ValueError:
2156+
failed.append(failure_name)
2157+
continue
2158+
2159+
if template_candidate.is_symlink() or not template_path.is_file():
2160+
failed.append(failure_name)
2161+
continue
2162+
2163+
if target_path.exists():
2164+
skipped_existing.append(target_name)
2165+
continue
2166+
2167+
target_path.parent.mkdir(parents=True, exist_ok=True)
2168+
try:
2169+
shutil.copy2(template_path, target_path)
2170+
except OSError:
2171+
failed.append(target_name)
2172+
continue
2173+
deployed.append(target_name)
2174+
2175+
return deployed, skipped_existing, failed
2176+
20912177
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
20922178
"""Remove an installed extension.
20932179

src/specify_cli/extensions/_commands.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,21 @@ def extension_add(
657657
if reg_skills:
658658
console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered")
659659

660+
# Scaffold config templates automatically
661+
deployed, skipped, failed = manager.scaffold_config(manifest.id)
662+
if deployed:
663+
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
664+
for cfg in deployed:
665+
console.print(f" • .specify/{_escape_markup(str(cfg))}")
666+
if skipped:
667+
console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]")
668+
if failed:
669+
console.print(
670+
f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: "
671+
f"{_escape_markup(', '.join(failed))}. "
672+
"Verify the extension manifest and template files."
673+
)
674+
660675
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
661676
console.print(f" Check: .specify/extensions/{_escape_markup(str(manifest.id))}/")
662677

@@ -1501,6 +1516,29 @@ def extension_enable(
15011516

15021517
console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' enabled")
15031518

1519+
# Scaffold config templates on enable
1520+
try:
1521+
deployed, skipped, failed = manager.scaffold_config(extension_id)
1522+
except Exception as exc:
1523+
console.print(
1524+
f"\n[yellow]Warning:[/yellow] Failed to scaffold config for extension "
1525+
f"'{_escape_markup(str(display_name))}'."
1526+
)
1527+
console.print(f"[dim]Details: {_escape_markup(str(exc))}[/dim]")
1528+
deployed, skipped, failed = [], [], []
1529+
if deployed:
1530+
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
1531+
for cfg in deployed:
1532+
console.print(f" • .specify/{_escape_markup(str(cfg))}")
1533+
if skipped:
1534+
console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]")
1535+
if failed:
1536+
console.print(
1537+
f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: "
1538+
f"{_escape_markup(', '.join(failed))}. "
1539+
"Verify the extension manifest and template files."
1540+
)
1541+
15041542

15051543
@extension_app.command("disable")
15061544
def extension_disable(

tests/test_extensions.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)