From 1f32ec05ce03952e5f45df24cfcebda46c67ce77 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:12:44 -0700 Subject: [PATCH 1/2] 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. --- src/specify_cli/extensions/__init__.py | 86 +++++++++ src/specify_cli/extensions/_commands.py | 39 +++++ tests/test_extensions.py | 220 ++++++++++++++++++++++++ 3 files changed, 345 insertions(+) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 354393b0da..4bd10c070c 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -559,6 +559,14 @@ def commands(self) -> List[Dict[str, Any]]: """Get list of provided commands.""" return self.data.get("provides", {}).get("commands", []) + @property + def config(self) -> List[Dict[str, Any]]: + """Get list of provided config templates, normalized to dictionaries.""" + raw = self.data.get("provides", {}).get("config", []) + if not isinstance(raw, list) or not all(isinstance(entry, dict) for entry in raw): + return [] + return raw + @property def hooks(self) -> Dict[str, Any]: """Get hook definitions.""" @@ -2470,6 +2478,84 @@ def install_from_zip( extension_dir, speckit_version, priority=priority, force=force ) + def scaffold_config(self, extension_id: str) -> tuple[List[str], List[str], List[str]]: + """Deploy config templates from an installed extension to the project. + + Reads the extension's manifest provides.config section and copies + each config template to the project's .specify/ directory. Existing + config files are never overwritten (user customizations are preserved). + + Args: + extension_id: ID of the installed extension + + Returns: + Tuple of (deployed, skipped_existing, failed) where each is a list + of config file names. + """ + ext_dir = self.extensions_dir / extension_id + manifest_path = ext_dir / "extension.yml" + if not manifest_path.exists(): + return [], [], [] + + manifest = ExtensionManifest(manifest_path) + deployed = [] + skipped_existing = [] + failed = [] + + provides = manifest.data.get("provides", {}) + raw_config = provides.get("config", []) + config_is_malformed = ( + "config" in provides + and ( + not isinstance(raw_config, list) + or not all(isinstance(entry, dict) for entry in raw_config) + ) + ) + if config_is_malformed: + return deployed, skipped_existing, ["provides.config"] + + ext_dir_resolved = ext_dir.resolve() + specify_dir_resolved = (self.project_root / ".specify").resolve() + + for config_entry in manifest.config: + template_name = config_entry.get("template", "") + target_name = config_entry.get("name", template_name) + failure_name = target_name if isinstance(target_name, str) and target_name else "provides.config" + if not isinstance(template_name, str) or not template_name: + failed.append(failure_name) + continue + if not isinstance(target_name, str) or not target_name: + failed.append(failure_name) + continue + + template_candidate = ext_dir / template_name + template_path = template_candidate.resolve() + target_path = (self.project_root / ".specify" / target_name).resolve() + try: + template_path.relative_to(ext_dir_resolved) + target_path.relative_to(specify_dir_resolved) + except ValueError: + failed.append(failure_name) + continue + + if template_candidate.is_symlink() or not template_path.is_file(): + failed.append(failure_name) + continue + + if target_path.exists(): + skipped_existing.append(target_name) + continue + + target_path.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.copy2(template_path, target_path) + except OSError: + failed.append(target_name) + continue + deployed.append(target_name) + + return deployed, skipped_existing, failed + def remove(self, extension_id: str, keep_config: bool = False) -> bool: """Remove an installed extension. diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 166364920b..9a1499306b 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -711,6 +711,21 @@ def extension_add( if reg_skills: console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered") + # Scaffold config templates automatically + deployed, skipped, failed = manager.scaffold_config(manifest.id) + if deployed: + console.print("\n[bold cyan]Config scaffolded:[/bold cyan]") + for cfg in deployed: + console.print(f" • .specify/{_escape_markup(str(cfg))}") + if skipped: + console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]") + if failed: + console.print( + f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: " + f"{_escape_markup(', '.join(failed))}. " + "Verify the extension manifest and template files." + ) + console.print("\n[yellow]⚠[/yellow] Configuration may be required") console.print(f" Check: .specify/extensions/{_escape_markup(str(manifest.id))}/") @@ -2218,6 +2233,30 @@ def extension_enable( # are re-emitted in installed integrations. _refresh_events_and_warn(project_root) + # Scaffold config templates on enable + try: + deployed, skipped, failed = manager.scaffold_config(extension_id) + except Exception as exc: + console.print( + f"\n[yellow]Warning:[/yellow] Failed to scaffold config for extension " + f"'{_escape_markup(str(display_name))}'." + ) + console.print(f"[dim]Details: {_escape_markup(str(exc))}[/dim]") + deployed, skipped, failed = [], [], [] + config_home = f".specify/extensions/{_escape_markup(str(extension_id))}" + if deployed: + console.print("\n[bold cyan]Config scaffolded:[/bold cyan]") + for cfg in deployed: + console.print(f" • {config_home}/{_escape_markup(str(cfg))}") + if skipped: + console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]") + if failed: + console.print( + f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: " + f"{_escape_markup(', '.join(failed))}. " + "Verify the extension manifest and template files." + ) + @extension_app.command("disable") def extension_disable( diff --git a/tests/test_extensions.py b/tests/test_extensions.py index ab31f12908..a41b48e840 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -10286,3 +10286,223 @@ def test_forge_extension_info_hyphenates_command_names( # not the manifest's dotted name. assert "speckit-test-ext-hello" in output, output assert "speckit.test-ext.hello" not in output, output + +# ===== Extension Config Scaffolding Tests ===== + + +class TestExtensionConfigScaffolding: + """Test automatic config scaffolding during add/enable lifecycle.""" + + def _make_extension(self, ext_dir, config_entries=None): + """Create a minimal extension with optional config templates.""" + ext_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "description": "Test extension", + "author": "Test", + "repository": "https://github.com/test/test", + "license": "MIT", + "homepage": "https://github.com/test/test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [{ + "name": "speckit.test-ext.example", + "file": "commands/example.md", + "description": "Example command", + }], + }, + "tags": ["test"], + } + if config_entries: + manifest["provides"]["config"] = config_entries + import yaml + (ext_dir / "extension.yml").write_text(yaml.dump(manifest, default_flow_style=False)) + # Create command file so validation passes + (ext_dir / "commands").mkdir(exist_ok=True) + (ext_dir / "commands" / "example.md").write_text("# Example") + return manifest + + def test_scaffold_config_deploys_template(self, tmp_path): + """Config template should be copied to .specify/ on scaffold.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": "test-config.yml", + "template": "config-template.yml", + "description": "Test config", + "required": True, + }]) + (ext_dir / "config-template.yml").write_text("setting: default") + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == ["test-config.yml"] + assert skipped == [] + assert failed == [] + assert (specify_dir / "test-config.yml").exists() + assert (specify_dir / "test-config.yml").read_text() == "setting: default" + + def test_scaffold_config_preserves_existing(self, tmp_path): + """Existing config files should never be overwritten.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + (specify_dir / "test-config.yml").write_text("setting: custom") + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": "test-config.yml", + "template": "config-template.yml", + "description": "Test config", + "required": True, + }]) + (ext_dir / "config-template.yml").write_text("setting: default") + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == ["test-config.yml"] + assert failed == [] + assert (specify_dir / "test-config.yml").read_text() == "setting: custom" + + def test_scaffold_config_no_config_section(self, tmp_path): + """Extensions without config section should return empty list.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir) + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == [] + + def test_scaffold_config_missing_template_file(self, tmp_path): + """Missing template files should be reported as failed.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": "test-config.yml", + "template": "nonexistent.yml", + "description": "Test config", + }]) + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == ["test-config.yml"] + + def test_scaffold_config_rejects_path_traversal(self, tmp_path): + """Config names with path traversal should be rejected.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[ + {"name": "../etc/passwd", "template": "config.yml"}, + {"name": "safe.yml", "template": "../../secrets.yml"}, + {"name": "/absolute/path.yml", "template": "config.yml"}, + ]) + (ext_dir / "config.yml").write_text("safe: true") + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == ["../etc/passwd", "safe.yml", "/absolute/path.yml"] + + def test_scaffold_config_rejects_directory_template(self, tmp_path): + """Directory templates should be rejected (must be regular files).""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": "test-config.yml", + "template": "config-dir", + }]) + (ext_dir / "config-dir").mkdir() + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == ["test-config.yml"] + + def test_scaffold_config_rejects_symlink_template(self, tmp_path): + """Symlink templates should not be copied.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": "test-config.yml", + "template": "config-link.yml", + }]) + real_template = ext_dir / "config-template.yml" + real_template.write_text("setting: default") + (ext_dir / "config-link.yml").symlink_to(real_template) + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == ["test-config.yml"] + assert not (specify_dir / "test-config.yml").exists() + + def test_scaffold_config_malformed_manifest(self, tmp_path): + """Malformed config sections should not crash.""" + from specify_cli.extensions import ExtensionManager, ExtensionManifest + import yaml + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + manifest_data = self._make_extension(ext_dir) + manifest_data["provides"]["config"] = "not-a-list" + (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data)) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + assert manifest.config == [] + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == ["provides.config"] + + def test_scaffold_config_missing_manifest_returns_consistent_result(self, tmp_path): + """A missing extension manifest should return the documented tuple.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + manager = ExtensionManager(project) + + assert manager.scaffold_config("missing") == ([], [], []) From 1db0301a812e76c23ed1ace175bf8e20c46d3c28 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:35:26 -0700 Subject: [PATCH 2/2] fix(extensions): deploy config where it is read, and contain the write Addresses @Copilot's review. Config now lands in .specify/extensions// rather than the .specify/ root. ConfigManager._get_project_config() reads .specify/extensions//-config.yml, and the bundled scripts and READMEs use the same path, so a scaffolded git-config.yml was being written somewhere the git extension never looks. Containment is checked component by component before .specify is used as the root. Resolving it first and trusting the result let a symlinked component point outside the project, after which every target satisfied relative_to and copy2 wrote externally. This matches the project safe-write path in shared_infra. mkdir moved inside the OSError handler. A nested target like foo/config.yml raised out of scaffolding when its parent could not be created, and on extension add that happened after the extension was already installed. The 'Configuration may be required' warning is now conditional. It ran unconditionally after the scaffolding block, so it contradicted the success output directly above it and fired for extensions with no provides.config at all. Tests cover the corrected location, a symlinked config root, and an uncreatable nested target. --- src/specify_cli/extensions/__init__.py | 50 +++++++++++++++-- src/specify_cli/extensions/_commands.py | 12 +++- tests/test_extensions.py | 73 +++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 4bd10c070c..1efacb183a 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2478,6 +2478,31 @@ def install_from_zip( extension_dir, speckit_version, priority=priority, force=force ) + def _config_root_is_contained(self, specify_dir: Path) -> bool: + """Report whether `.specify` is a real directory inside the project. + + Checked component by component so a symlink anywhere on the path is + rejected before it becomes the containment root. A missing `.specify` + is fine: scaffolding creates it under the project root. + """ + try: + root = self.project_root.resolve() + except OSError: + return False + current = self.project_root + for part in specify_dir.relative_to(self.project_root).parts: + current = current / part + if current.is_symlink(): + return False + if not current.exists(): + return True + try: + if current.resolve().relative_to(root) is None: + return False + except (OSError, ValueError): + return False + return current.is_dir() + def scaffold_config(self, extension_id: str) -> tuple[List[str], List[str], List[str]]: """Deploy config templates from an installed extension to the project. @@ -2515,7 +2540,20 @@ def scaffold_config(self, extension_id: str) -> tuple[List[str], List[str], List return deployed, skipped_existing, ["provides.config"] ext_dir_resolved = ext_dir.resolve() - specify_dir_resolved = (self.project_root / ".specify").resolve() + # Config is deployed beneath the extension's own directory because that + # is where it is read from: ConfigManager._get_project_config() loads + # `.specify/extensions//-config.yml`, and the bundled scripts + # and READMEs use the same location. Writing to `.specify/` put + # the file somewhere nothing ever looks. + config_dir = self.project_root / ".specify" / "extensions" / extension_id + # Resolving that directory and trusting the result as the containment + # root lets a symlinked component point outside the project: every + # target would then satisfy relative_to and copy2 would write + # externally. Refuse a symlinked component up front, matching the + # project safe-write path in shared_infra. + if not self._config_root_is_contained(config_dir): + return deployed, skipped_existing, ["provides.config"] + config_dir_resolved = config_dir.resolve() for config_entry in manifest.config: template_name = config_entry.get("template", "") @@ -2530,10 +2568,10 @@ def scaffold_config(self, extension_id: str) -> tuple[List[str], List[str], List template_candidate = ext_dir / template_name template_path = template_candidate.resolve() - target_path = (self.project_root / ".specify" / target_name).resolve() + target_path = (config_dir / target_name).resolve() try: template_path.relative_to(ext_dir_resolved) - target_path.relative_to(specify_dir_resolved) + target_path.relative_to(config_dir_resolved) except ValueError: failed.append(failure_name) continue @@ -2546,8 +2584,12 @@ def scaffold_config(self, extension_id: str) -> tuple[List[str], List[str], List skipped_existing.append(target_name) continue - target_path.parent.mkdir(parents=True, exist_ok=True) try: + # mkdir belongs inside the handler: a nested target like + # foo/config.yml must land in `failed` when `.specify/foo` is a + # file or cannot be created, not raise out of scaffolding after + # `extension add` has already installed the extension. + target_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(template_path, target_path) except OSError: failed.append(target_name) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 9a1499306b..a4b4cba984 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -713,10 +713,11 @@ def extension_add( # Scaffold config templates automatically deployed, skipped, failed = manager.scaffold_config(manifest.id) + config_home = f".specify/extensions/{_escape_markup(str(manifest.id))}" if deployed: console.print("\n[bold cyan]Config scaffolded:[/bold cyan]") for cfg in deployed: - console.print(f" • .specify/{_escape_markup(str(cfg))}") + console.print(f" • {config_home}/{_escape_markup(str(cfg))}") if skipped: console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]") if failed: @@ -726,8 +727,13 @@ def extension_add( "Verify the extension manifest and template files." ) - console.print("\n[yellow]⚠[/yellow] Configuration may be required") - console.print(f" Check: .specify/extensions/{_escape_markup(str(manifest.id))}/") + # Only warn when configuration is actually unresolved. Scaffolding that + # deployed or preserved every template has already answered this, and an + # extension without provides.config has nothing to configure; the blanket + # warning contradicted the output directly above it. + if failed or not (deployed or skipped): + console.print("\n[yellow]⚠[/yellow] Configuration may be required") + console.print(f" Check: {config_home}/") except ValidationError as e: console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index a41b48e840..3a2aeb70d7 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -10328,7 +10328,7 @@ def _make_extension(self, ext_dir, config_entries=None): return manifest def test_scaffold_config_deploys_template(self, tmp_path): - """Config template should be copied to .specify/ on scaffold.""" + """Config template lands where ConfigManager reads it, not in .specify/ root.""" from specify_cli.extensions import ExtensionManager project = tmp_path / "project" specify_dir = project / ".specify" @@ -10348,8 +10348,13 @@ def test_scaffold_config_deploys_template(self, tmp_path): assert deployed == ["test-config.yml"] assert skipped == [] assert failed == [] - assert (specify_dir / "test-config.yml").exists() - assert (specify_dir / "test-config.yml").read_text() == "setting: default" + # ConfigManager._get_project_config() reads + # .specify/extensions//, so that is where scaffolding must + # put it. Deploying to the .specify/ root left the file somewhere the + # extension never looks. + assert (ext_dir / "test-config.yml").exists() + assert (ext_dir / "test-config.yml").read_text() == "setting: default" + assert not (specify_dir / "test-config.yml").exists() def test_scaffold_config_preserves_existing(self, tmp_path): """Existing config files should never be overwritten.""" @@ -10357,7 +10362,6 @@ def test_scaffold_config_preserves_existing(self, tmp_path): project = tmp_path / "project" specify_dir = project / ".specify" specify_dir.mkdir(parents=True) - (specify_dir / "test-config.yml").write_text("setting: custom") ext_dir = specify_dir / "extensions" / "test-ext" self._make_extension(ext_dir, config_entries=[{ "name": "test-config.yml", @@ -10366,6 +10370,7 @@ def test_scaffold_config_preserves_existing(self, tmp_path): "required": True, }]) (ext_dir / "config-template.yml").write_text("setting: default") + (ext_dir / "test-config.yml").write_text("setting: custom") manager = ExtensionManager(project) deployed, skipped, failed = manager.scaffold_config("test-ext") @@ -10373,7 +10378,7 @@ def test_scaffold_config_preserves_existing(self, tmp_path): assert deployed == [] assert skipped == ["test-config.yml"] assert failed == [] - assert (specify_dir / "test-config.yml").read_text() == "setting: custom" + assert (ext_dir / "test-config.yml").read_text() == "setting: custom" def test_scaffold_config_no_config_section(self, tmp_path): """Extensions without config section should return empty list.""" @@ -10506,3 +10511,61 @@ def test_scaffold_config_missing_manifest_returns_consistent_result(self, tmp_pa manager = ExtensionManager(project) assert manager.scaffold_config("missing") == ([], [], []) + + def test_scaffold_config_rejects_symlinked_config_root(self, tmp_path): + """A symlinked .specify must not become the containment root. + + Resolving .specify first and trusting the result lets a symlink point + anywhere: every target then satisfies relative_to and copy2 writes + outside the project. + """ + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + project.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (project / ".specify").symlink_to(outside, target_is_directory=True) + + ext_dir = outside / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": "test-config.yml", + "template": "config-template.yml", + "description": "Test config", + "required": True, + }]) + (ext_dir / "config-template.yml").write_text("setting: default") + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == ["provides.config"] + assert not (outside / "extensions" / "test-ext" / "test-config.yml").exists() + + def test_scaffold_config_reports_unwritable_nested_target(self, tmp_path): + """A nested target whose parent cannot be created is a failure, not a crash. + + mkdir used to run outside the OSError handler, so this raised out of + scaffolding after `extension add` had already installed the extension. + """ + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": "nested/test-config.yml", + "template": "config-template.yml", + "description": "Test config", + "required": True, + }]) + (ext_dir / "config-template.yml").write_text("setting: default") + # `nested` is a file, so creating it as a directory fails. + (ext_dir / "nested").write_text("not a directory") + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert failed == ["nested/test-config.yml"]