diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 0936e5a445..edfa46bed4 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -562,6 +562,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.""" @@ -2489,6 +2497,149 @@ def install_from_archive( 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() + + @staticmethod + def _target_follows_preserved_convention(target_name: str) -> bool: + """True when a scaffold target survives remove/backup/restore. + + Those paths only handle top-level ``*-config.yml`` and + ``*-config.local.yml`` files, so anything nested or otherwise named is + not preserved across an update. + """ + if "/" in target_name or "\\" in target_name: + return False + return target_name.endswith("-config.yml") or target_name.endswith( + "-config.local.yml" + ) + + 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() + # 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", "") + 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 + # Only scaffold what removal actually preserves. remove(keep_config) + # keeps top-level files ending in -config.yml / -config.local.yml and + # rmtree's every subdirectory; the backup path globs the same + # top-level pattern. A nested or differently-named target would be + # silently destroyed by `extension add --force` and replaced with the + # template default, losing the user's customization. + if not self._target_follows_preserved_convention(target_name): + failed.append(failure_name) + continue + + template_candidate = ext_dir / template_name + template_path = template_candidate.resolve() + target_path = (config_dir / target_name).resolve() + try: + template_path.relative_to(ext_dir_resolved) + target_path.relative_to(config_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 + + 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) + continue + deployed.append(target_name) + + return deployed, skipped_existing, failed + def install_from_zip( self, zip_path: Path, diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 2841aa376e..1e78ee8116 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -1071,8 +1071,29 @@ def extension_add( if reg_skills: console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered") - console.print("\n[yellow]⚠[/yellow] Configuration may be required") - console.print(f" Check: .specify/extensions/{_escape_markup(str(manifest.id))}/") + # 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" • {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." + ) + + # 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))}") @@ -2552,6 +2573,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 63df3133fe..3ee9a13aa7 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -10454,3 +10454,310 @@ 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 lands where ConfigManager reads it, not in .specify/ root.""" + 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 == [] + # 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.""" + 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") + (ext_dir / "test-config.yml").write_text("setting: custom") + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == ["test-config.yml"] + assert failed == [] + 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.""" + 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") == ([], [], []) + + 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_rejects_targets_removal_would_not_preserve(self, tmp_path): + """Only top-level *-config.yml targets are scaffolded. + + remove(keep_config=True) rmtree's every subdirectory and keeps only + top-level -config.yml / -config.local.yml files, and the backup path + globs the same pattern. Scaffolding anything else would hand the user a + file that `extension add --force` silently replaces with the template + default. + """ + from specify_cli.extensions import ExtensionManager + for target in ("nested/test-config.yml", "settings.yml", "test-config.yaml"): + project = tmp_path / f"project-{target.replace('/', '_')}" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": target, + "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 == [], target + assert skipped == [], target + assert failed == [target], target + + def test_scaffold_config_accepts_local_override_name(self, tmp_path): + """*-config.local.yml is preserved by removal, so it may be scaffolded.""" + 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.local.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.local.yml"] + assert failed == []