From 59081cceb98dcea21db970725903cb5ffbd01f08 Mon Sep 17 00:00:00 2001 From: Grissiom Date: Tue, 12 May 2026 23:10:56 +0800 Subject: [PATCH 1/6] feat(extensions): add --force flag to extension add for overwrite reinstall Add --force support to `specify extension add` that allows overwriting an already-installed extension without manually removing it first. - install_from_directory() and install_from_zip() accept force=True, automatically calling remove() before installation - The --force CLI flag works with all install modes (--dev, --from URL, bundled, and catalog) - Config files (*-config.yml) are preserved across force reinstall - Error message suggests --force when extension is already installed - 6 new tests covering unit and CLI force reinstall flows --- src/specify_cli/__init__.py | 20 ++++- src/specify_cli/extensions.py | 36 ++++++-- tests/test_extensions.py | 161 ++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 10 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 684dc5b579..2e6a346e5b 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1611,6 +1611,7 @@ def extension_add( extension: str = typer.Argument(help="Extension name or path"), dev: bool = typer.Option(False, "--dev", help="Install from local directory"), from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), + force: bool = typer.Option(False, "--force", help="Overwrite if already installed"), priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), ): """Install an extension.""" @@ -1625,6 +1626,9 @@ def extension_add( manager = ExtensionManager(project_root) speckit_version = get_speckit_version() + if force: + console.print("[yellow]--force:[/yellow] Will overwrite if already installed\n") + # Prompt for URL-based installs BEFORE the spinner so the user can # actually see and respond to the confirmation (the Rich status # spinner overwrites the typer.confirm prompt line, making it appear @@ -1675,11 +1679,15 @@ def extension_add( console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") raise typer.Exit(1) + if force: + console.print(f"[yellow]--force:[/yellow] Reinstalling from [cyan]{source_path}[/cyan]...") + manifest = manager.install_from_directory( source_path, speckit_version, priority=priority, link_commands=True, + force=force ) elif from_url: @@ -1701,7 +1709,7 @@ def extension_add( zip_path.write_bytes(zip_data) # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force) except urllib.error.URLError as e: console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}") raise typer.Exit(1) @@ -1714,7 +1722,9 @@ def extension_add( # Try bundled extensions first (shipped with spec-kit) bundled_path = _locate_bundled_extension(extension) if bundled_path is not None: - manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) + manifest = manager.install_from_directory( + bundled_path, speckit_version, priority=priority, force=force + ) else: # Install from catalog (also resolves display names to IDs) catalog = ExtensionCatalog(project_root) @@ -1735,7 +1745,9 @@ def extension_add( if resolved_id != extension: bundled_path = _locate_bundled_extension(resolved_id) if bundled_path is not None: - manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) + manifest = manager.install_from_directory( + bundled_path, speckit_version, priority=priority, force=force + ) if bundled_path is None: # Bundled extensions without a download URL must come from the local package @@ -1771,7 +1783,7 @@ def extension_add( try: # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority) + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force) finally: # Clean up downloaded ZIP if zip_path.exists(): diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index a754d33d9f..6e32c5cc41 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1173,6 +1173,7 @@ def install_from_directory( register_commands: bool = True, priority: int = 10, link_commands: bool = False, + force: bool = False, ) -> ExtensionManifest: """Install extension from a local directory. @@ -1183,6 +1184,8 @@ def install_from_directory( priority: Resolution priority (lower = higher precedence, default 10) link_commands: If True, register rendered agent artifacts as symlinks to a dev cache when supported by the OS. + force: If True and extension is already installed, remove it first + before proceeding with installation Returns: Installed extension manifest @@ -1204,10 +1207,17 @@ def install_from_directory( # Check if already installed if self.registry.is_installed(manifest.id): - raise ExtensionError( - f"Extension '{manifest.id}' is already installed. " - f"Use 'specify extension remove {manifest.id}' first." - ) + if force: + # Remove existing extension first (handles command/skill/hook + # cleanup, config backup, and registry removal). + backup_config_dir = self.extensions_dir / ".backup" / manifest.id + self.remove(manifest.id) + else: + raise ExtensionError( + f"Extension '{manifest.id}' is already installed. " + f"Use 'specify extension remove {manifest.id}' first, " + f"or retry with --force to overwrite." + ) # Reject manifests that would shadow core commands or installed extensions. self._validate_install_conflicts(manifest) @@ -1239,6 +1249,15 @@ def install_from_directory( hook_executor = HookExecutor(self.project_root) hook_executor.register_hooks(manifest) + # Restore config files from backup when reinstalling with --force + if force: + backup_config_dir = self.extensions_dir / ".backup" / manifest.id + if backup_config_dir.exists(): + for cfg_file in backup_config_dir.iterdir(): + if cfg_file.is_file(): + shutil.copy2(cfg_file, dest_dir / cfg_file.name) + shutil.rmtree(backup_config_dir) + # Update registry self.registry.add(manifest.id, { "version": manifest.version, @@ -1257,6 +1276,7 @@ def install_from_zip( zip_path: Path, speckit_version: str, priority: int = 10, + force: bool = False, ) -> ExtensionManifest: """Install extension from ZIP file. @@ -1264,6 +1284,8 @@ def install_from_zip( zip_path: Path to extension ZIP file speckit_version: Current spec-kit version priority: Resolution priority (lower = higher precedence, default 10) + force: If True and extension is already installed, remove it first + before proceeding with installation Returns: Installed extension manifest @@ -1310,7 +1332,9 @@ def install_from_zip( raise ValidationError("No extension.yml found in ZIP file") # Install from extracted directory - return self.install_from_directory(extension_dir, speckit_version, priority=priority) + return self.install_from_directory( + extension_dir, speckit_version, priority=priority, force=force + ) def remove(self, extension_id: str, keep_config: bool = False) -> bool: """Remove an installed extension. @@ -2742,7 +2766,7 @@ def unregister_hooks(self, extension_id: str): if not isinstance(config, dict): config = {} - # We don't save yet, as there are no hooks to unregister, + # We don't save yet, as there are no hooks to unregister, # but unregister_extension above might have already saved a normalized config. return diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 26e04afe2d..16e0a08638 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -793,6 +793,101 @@ def test_install_duplicate(self, extension_dir, project_dir): with pytest.raises(ExtensionError, match="already installed"): manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + def test_install_force_reinstall(self, extension_dir, project_dir): + """Test force-reinstalling an already-installed extension.""" + manager = ExtensionManager(project_dir) + + # Install once + manifest1 = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + assert manager.registry.is_installed("test-ext") + + # Force-reinstall + manifest2 = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False, force=True + ) + + assert manifest2.id == "test-ext" + assert manager.registry.is_installed("test-ext") + # Check extension directory was recreated + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + assert ext_dir.exists() + assert (ext_dir / "extension.yml").exists() + assert (ext_dir / "commands" / "hello.md").exists() + + def test_install_force_config_preserved(self, extension_dir, project_dir): + """Test that config files are preserved when force-reinstalling.""" + manager = ExtensionManager(project_dir) + + # Install once + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Create a config file in the installed extension directory + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + config_file = ext_dir / "test-ext-config.yml" + config_file.write_text("test: config") + + # Force-reinstall + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False, force=True + ) + + # Config file should still exist after reinstall + new_config = ext_dir / "test-ext-config.yml" + assert new_config.exists() + assert new_config.read_text() == "test: config" + + def test_install_force_without_existing(self, extension_dir, project_dir): + """Test force-install when extension is NOT already installed (works normally).""" + manager = ExtensionManager(project_dir) + + manifest = manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False, force=True + ) + + assert manifest.id == "test-ext" + assert manager.registry.is_installed("test-ext") + + def test_install_zip_force_reinstall(self, extension_dir, project_dir): + """Test force-reinstalling from ZIP when already installed.""" + import zipfile + import tempfile + + manager = ExtensionManager(project_dir) + + # Install once from directory + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + # Create a ZIP of the extension + with tempfile.NamedTemporaryFile(suffix=".zip") as tmp: + zip_path = Path(tmp.name) + with zipfile.ZipFile(zip_path, "w") as zf: + for f in extension_dir.rglob("*"): + if f.is_file(): + zf.write(f, f.relative_to(extension_dir)) + + # Force-reinstall from ZIP + manifest = manager.install_from_zip( + zip_path, "0.1.0", force=True + ) + + assert manifest.id == "test-ext" + assert manager.registry.is_installed("test-ext") + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + assert ext_dir.exists() + + def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir): + """Test that duplicate install error message suggests --force.""" + manager = ExtensionManager(project_dir) + + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + with pytest.raises(ExtensionError, match="--force"): + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_dir): """Install should reject extension IDs that shadow core commands.""" import yaml @@ -5114,3 +5209,69 @@ def test_non_cline_extension_no_hyphenation(self, tmp_path): # Verify body references are still dotted for non-Cline assert "speckit.mock-ext.greet" in hello_body assert "speckit-mock-ext-greet" not in hello_body + + +class TestExtensionForceCLI: + """CLI tests for `specify extension add --dev --force`.""" + + def _create_minimal_extension(self, base_dir: str | Path, ext_id: str = "test-ext") -> Path: + """Create a minimal extension directory with manifest.""" + import yaml + + ext_dir = Path(base_dir) / ext_id + ext_dir.mkdir(parents=True, exist_ok=True) + (ext_dir / "commands").mkdir() + + manifest = { + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": "Test Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": f"speckit.{ext_id}.hello", + "file": "commands/hello.md", + "description": "Test command", + } + ] + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest)) + (ext_dir / "commands" / "hello.md").write_text( + "---\ndescription: Test\n---\n\nHello $ARGUMENTS\n" + ) + return ext_dir + + def test_add_dev_force_reinstall(self, tmp_path): + """extension add --dev --force should reinstall without error.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + ext_src = self._create_minimal_extension(tmp_path) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + # First install + result1 = runner.invoke( + app, ["extension", "add", str(ext_src), "--dev"], catch_exceptions=False + ) + assert result1.exit_code == 0, strip_ansi(result1.output) + assert "installed" in strip_ansi(result1.output) + + # Force reinstall + result2 = runner.invoke( + app, ["extension", "add", str(ext_src), "--dev", "--force"], catch_exceptions=False + ) + assert result2.exit_code == 0, strip_ansi(result2.output) + assert "installed" in strip_ansi(result2.output) From 5238cfddcc1a2efffe6a34f019a7122929389e2c Mon Sep 17 00:00:00 2001 From: Grissiom Date: Wed, 13 May 2026 21:35:44 +0800 Subject: [PATCH 2/6] fix: address PR review feedback on --force implementation - Remove unused `backup_config_dir` variable assignment (Ruff F841) - Defer `remove()` until after `_validate_install_conflicts()` to prevent data loss if validation fails mid-reinstall - Use `TemporaryDirectory` instead of `NamedTemporaryFile` in ZIP test to avoid Windows file-locking failures --- src/specify_cli/extensions.py | 13 +++++++------ tests/test_extensions.py | 7 ++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 6e32c5cc41..d8bc16f757 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1207,12 +1207,7 @@ def install_from_directory( # Check if already installed if self.registry.is_installed(manifest.id): - if force: - # Remove existing extension first (handles command/skill/hook - # cleanup, config backup, and registry removal). - backup_config_dir = self.extensions_dir / ".backup" / manifest.id - self.remove(manifest.id) - else: + if not force: raise ExtensionError( f"Extension '{manifest.id}' is already installed. " f"Use 'specify extension remove {manifest.id}' first, " @@ -1222,6 +1217,12 @@ def install_from_directory( # Reject manifests that would shadow core commands or installed extensions. self._validate_install_conflicts(manifest) + # Remove existing installation AFTER all validations pass so that a + # validation failure doesn't leave the user with a half-uninstalled + # extension (configs stranded in .backup/). + if force and self.registry.is_installed(manifest.id): + self.remove(manifest.id) + # Install extension dest_dir = self.extensions_dir / manifest.id if dest_dir.exists(): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 16e0a08638..9892159a36 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -861,9 +861,10 @@ def test_install_zip_force_reinstall(self, extension_dir, project_dir): # Install once from directory manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) - # Create a ZIP of the extension - with tempfile.NamedTemporaryFile(suffix=".zip") as tmp: - zip_path = Path(tmp.name) + # Create a ZIP of the extension in a temp directory (not NamedTemporaryFile, + # which can fail on Windows due to file locking). + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = Path(tmpdir) / "test-ext.zip" with zipfile.ZipFile(zip_path, "w") as zf: for f in extension_dir.rglob("*"): if f.is_file(): From d88ff5a3577081d29af581bdd0351e62ac147c6a Mon Sep 17 00:00:00 2001 From: Grissiom Date: Sat, 16 May 2026 18:34:29 +0800 Subject: [PATCH 3/6] fix: only restore config backup when --force actually triggers a remove When --force is used but the extension is not already installed, the backup restore/cleanup should not run. Previously it could resurrect stale config files from a previous removal and delete the backup directory unnecessarily. --- src/specify_cli/extensions.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index d8bc16f757..db9cbd688e 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1220,8 +1220,9 @@ def install_from_directory( # Remove existing installation AFTER all validations pass so that a # validation failure doesn't leave the user with a half-uninstalled # extension (configs stranded in .backup/). + did_remove = False if force and self.registry.is_installed(manifest.id): - self.remove(manifest.id) + did_remove = self.remove(manifest.id) # Install extension dest_dir = self.extensions_dir / manifest.id @@ -1250,8 +1251,11 @@ def install_from_directory( hook_executor = HookExecutor(self.project_root) hook_executor.register_hooks(manifest) - # Restore config files from backup when reinstalling with --force - if force: + # Restore config files from backup when --force triggered a removal + # Only restore when a remove was actually performed, so that stale + # backup files from a previous removal don't get resurrected when the + # extension wasn't already installed. + if did_remove: backup_config_dir = self.extensions_dir / ".backup" / manifest.id if backup_config_dir.exists(): for cfg_file in backup_config_dir.iterdir(): From 9a48366deaefc0e8476ba2a1350d69822afc7974 Mon Sep 17 00:00:00 2001 From: Grissiom Date: Sat, 23 May 2026 12:21:38 +0800 Subject: [PATCH 4/6] fix: address Copilot review feedback on --force implementation - Clear stale backup dir before remove() so only fresh backups are restored - Restore only config files (*-config.yml, *-config.local.yml) from backup - Remove trailing \n from --force console message (console.print adds newline) --- src/specify_cli/__init__.py | 2 +- src/specify_cli/extensions.py | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 2e6a346e5b..d8a77e9ccb 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1627,7 +1627,7 @@ def extension_add( speckit_version = get_speckit_version() if force: - console.print("[yellow]--force:[/yellow] Will overwrite if already installed\n") + console.print("[yellow]--force:[/yellow] Will overwrite if already installed") # Prompt for URL-based installs BEFORE the spinner so the user can # actually see and respond to the confirmation (the Rich status diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index db9cbd688e..36251abd06 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1222,6 +1222,11 @@ def install_from_directory( # extension (configs stranded in .backup/). did_remove = False if force and self.registry.is_installed(manifest.id): + # Clear any stale backup from a previous remove so that only the + # backup produced by the current remove() call is restored later. + backup_config_dir = self.extensions_dir / ".backup" / manifest.id + if backup_config_dir.exists(): + shutil.rmtree(backup_config_dir) did_remove = self.remove(manifest.id) # Install extension @@ -1251,15 +1256,17 @@ def install_from_directory( hook_executor = HookExecutor(self.project_root) hook_executor.register_hooks(manifest) - # Restore config files from backup when --force triggered a removal - # Only restore when a remove was actually performed, so that stale - # backup files from a previous removal don't get resurrected when the - # extension wasn't already installed. + # Restore config files from backup when --force triggered a removal. + # Only restore *.yml config files to match what remove() backs up, + # so unexpected artifacts in .backup/ are not resurrected. if did_remove: backup_config_dir = self.extensions_dir / ".backup" / manifest.id if backup_config_dir.exists(): for cfg_file in backup_config_dir.iterdir(): - if cfg_file.is_file(): + if cfg_file.is_file() and ( + cfg_file.name.endswith("-config.yml") or + cfg_file.name.endswith("-config.local.yml") + ): shutil.copy2(cfg_file, dest_dir / cfg_file.name) shutil.rmtree(backup_config_dir) From 3b711a3f4446bed3cfcaea3a119cb49c4469bcf4 Mon Sep 17 00:00:00 2001 From: Grissiom Date: Sat, 30 May 2026 11:48:29 +0800 Subject: [PATCH 5/6] fix: handle non-directory paths in backup cleanup/restore - Use is_dir() before rmtree/iterdir on backup path to avoid crashes when .backup/ exists as a file or symlink - Remove unused manifest1 variable in test_install_force_reinstall --- src/specify_cli/extensions.py | 11 +++++++++-- tests/test_extensions.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 36251abd06..486a3c9972 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1225,8 +1225,12 @@ def install_from_directory( # Clear any stale backup from a previous remove so that only the # backup produced by the current remove() call is restored later. backup_config_dir = self.extensions_dir / ".backup" / manifest.id - if backup_config_dir.exists(): + if backup_config_dir.is_dir(): shutil.rmtree(backup_config_dir) + elif backup_config_dir.exists(): + # Handle the unlikely case of a file or symlink at the + # backup path (e.g. from manual user intervention). + backup_config_dir.unlink() did_remove = self.remove(manifest.id) # Install extension @@ -1261,7 +1265,7 @@ def install_from_directory( # so unexpected artifacts in .backup/ are not resurrected. if did_remove: backup_config_dir = self.extensions_dir / ".backup" / manifest.id - if backup_config_dir.exists(): + if backup_config_dir.is_dir(): for cfg_file in backup_config_dir.iterdir(): if cfg_file.is_file() and ( cfg_file.name.endswith("-config.yml") or @@ -1269,6 +1273,9 @@ def install_from_directory( ): shutil.copy2(cfg_file, dest_dir / cfg_file.name) shutil.rmtree(backup_config_dir) + elif backup_config_dir.exists(): + # Defensive: remove unexpected non-directory at backup path. + backup_config_dir.unlink() # Update registry self.registry.add(manifest.id, { diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 9892159a36..f106fa6a2f 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -798,7 +798,7 @@ def test_install_force_reinstall(self, extension_dir, project_dir): manager = ExtensionManager(project_dir) # Install once - manifest1 = manager.install_from_directory( + manager.install_from_directory( extension_dir, "0.1.0", register_commands=False ) assert manager.registry.is_installed("test-ext") From 5b97266ad7dae23b8cd75f674e3d217aeb5ba488 Mon Sep 17 00:00:00 2001 From: Grissiom Date: Wed, 3 Jun 2026 22:58:54 +0800 Subject: [PATCH 6/6] fix: handle symlinks in backup cleanup/restore and correct CLI message - Check is_symlink() before is_dir() in backup cleanup and restore: Path.is_dir() follows symlinks (returns True for symlink-to-dir) but shutil.rmtree() raises OSError on symlinks. Handle symlinks by unlinking them instead. - Skip symlink entries during config file restore. - Change --force dev-install message from "Reinstalling" to "Installing [...] (will overwrite if already installed)" because --force also works for first-time installs. --- src/specify_cli/__init__.py | 2 +- src/specify_cli/extensions.py | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d8a77e9ccb..093a5f07e9 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1680,7 +1680,7 @@ def extension_add( raise typer.Exit(1) if force: - console.print(f"[yellow]--force:[/yellow] Reinstalling from [cyan]{source_path}[/cyan]...") + console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{source_path}[/cyan] (will overwrite if already installed)...") manifest = manager.install_from_directory( source_path, diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 486a3c9972..ab95559140 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1225,11 +1225,13 @@ def install_from_directory( # Clear any stale backup from a previous remove so that only the # backup produced by the current remove() call is restored later. backup_config_dir = self.extensions_dir / ".backup" / manifest.id - if backup_config_dir.is_dir(): + # Check is_symlink first: is_dir() follows symlinks so a + # symlink-to-directory would pass, but rmtree() raises on them. + if backup_config_dir.is_symlink(): + backup_config_dir.unlink() + elif backup_config_dir.is_dir(): shutil.rmtree(backup_config_dir) elif backup_config_dir.exists(): - # Handle the unlikely case of a file or symlink at the - # backup path (e.g. from manual user intervention). backup_config_dir.unlink() did_remove = self.remove(manifest.id) @@ -1265,16 +1267,19 @@ def install_from_directory( # so unexpected artifacts in .backup/ are not resurrected. if did_remove: backup_config_dir = self.extensions_dir / ".backup" / manifest.id - if backup_config_dir.is_dir(): + # is_symlink first: is_dir() follows symlinks, but rmtree() + # raises on them — and we shouldn't follow symlinks to restore. + if backup_config_dir.is_symlink(): + backup_config_dir.unlink() + elif backup_config_dir.is_dir(): for cfg_file in backup_config_dir.iterdir(): - if cfg_file.is_file() and ( + if cfg_file.is_file() and not cfg_file.is_symlink() and ( cfg_file.name.endswith("-config.yml") or cfg_file.name.endswith("-config.local.yml") ): shutil.copy2(cfg_file, dest_dir / cfg_file.name) shutil.rmtree(backup_config_dir) elif backup_config_dir.exists(): - # Defensive: remove unexpected non-directory at backup path. backup_config_dir.unlink() # Update registry