Skip to content

Commit 659a41a

Browse files
authored
feat(extensions): add --force flag to extension add for overwrite reinstall (#2530)
* 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 * 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 * 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. * 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) * fix: handle non-directory paths in backup cleanup/restore - Use is_dir() before rmtree/iterdir on backup path to avoid crashes when .backup/<id> exists as a file or symlink - Remove unused manifest1 variable in test_install_force_reinstall * 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.
1 parent df09fd4 commit 659a41a

3 files changed

Lines changed: 232 additions & 10 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1611,6 +1611,7 @@ def extension_add(
16111611
extension: str = typer.Argument(help="Extension name or path"),
16121612
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
16131613
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
1614+
force: bool = typer.Option(False, "--force", help="Overwrite if already installed"),
16141615
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
16151616
):
16161617
"""Install an extension."""
@@ -1625,6 +1626,9 @@ def extension_add(
16251626
manager = ExtensionManager(project_root)
16261627
speckit_version = get_speckit_version()
16271628

1629+
if force:
1630+
console.print("[yellow]--force:[/yellow] Will overwrite if already installed")
1631+
16281632
# Prompt for URL-based installs BEFORE the spinner so the user can
16291633
# actually see and respond to the confirmation (the Rich status
16301634
# spinner overwrites the typer.confirm prompt line, making it appear
@@ -1675,11 +1679,15 @@ def extension_add(
16751679
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
16761680
raise typer.Exit(1)
16771681

1682+
if force:
1683+
console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{source_path}[/cyan] (will overwrite if already installed)...")
1684+
16781685
manifest = manager.install_from_directory(
16791686
source_path,
16801687
speckit_version,
16811688
priority=priority,
16821689
link_commands=True,
1690+
force=force
16831691
)
16841692

16851693
elif from_url:
@@ -1701,7 +1709,7 @@ def extension_add(
17011709
zip_path.write_bytes(zip_data)
17021710

17031711
# Install from downloaded ZIP
1704-
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
1712+
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
17051713
except urllib.error.URLError as e:
17061714
console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}")
17071715
raise typer.Exit(1)
@@ -1714,7 +1722,9 @@ def extension_add(
17141722
# Try bundled extensions first (shipped with spec-kit)
17151723
bundled_path = _locate_bundled_extension(extension)
17161724
if bundled_path is not None:
1717-
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
1725+
manifest = manager.install_from_directory(
1726+
bundled_path, speckit_version, priority=priority, force=force
1727+
)
17181728
else:
17191729
# Install from catalog (also resolves display names to IDs)
17201730
catalog = ExtensionCatalog(project_root)
@@ -1735,7 +1745,9 @@ def extension_add(
17351745
if resolved_id != extension:
17361746
bundled_path = _locate_bundled_extension(resolved_id)
17371747
if bundled_path is not None:
1738-
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
1748+
manifest = manager.install_from_directory(
1749+
bundled_path, speckit_version, priority=priority, force=force
1750+
)
17391751

17401752
if bundled_path is None:
17411753
# Bundled extensions without a download URL must come from the local package
@@ -1771,7 +1783,7 @@ def extension_add(
17711783

17721784
try:
17731785
# Install from downloaded ZIP
1774-
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
1786+
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
17751787
finally:
17761788
# Clean up downloaded ZIP
17771789
if zip_path.exists():

src/specify_cli/extensions.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,7 @@ def install_from_directory(
11731173
register_commands: bool = True,
11741174
priority: int = 10,
11751175
link_commands: bool = False,
1176+
force: bool = False,
11761177
) -> ExtensionManifest:
11771178
"""Install extension from a local directory.
11781179
@@ -1183,6 +1184,8 @@ def install_from_directory(
11831184
priority: Resolution priority (lower = higher precedence, default 10)
11841185
link_commands: If True, register rendered agent artifacts as
11851186
symlinks to a dev cache when supported by the OS.
1187+
force: If True and extension is already installed, remove it first
1188+
before proceeding with installation
11861189
11871190
Returns:
11881191
Installed extension manifest
@@ -1204,14 +1207,34 @@ def install_from_directory(
12041207

12051208
# Check if already installed
12061209
if self.registry.is_installed(manifest.id):
1207-
raise ExtensionError(
1208-
f"Extension '{manifest.id}' is already installed. "
1209-
f"Use 'specify extension remove {manifest.id}' first."
1210-
)
1210+
if not force:
1211+
raise ExtensionError(
1212+
f"Extension '{manifest.id}' is already installed. "
1213+
f"Use 'specify extension remove {manifest.id}' first, "
1214+
f"or retry with --force to overwrite."
1215+
)
12111216

12121217
# Reject manifests that would shadow core commands or installed extensions.
12131218
self._validate_install_conflicts(manifest)
12141219

1220+
# Remove existing installation AFTER all validations pass so that a
1221+
# validation failure doesn't leave the user with a half-uninstalled
1222+
# extension (configs stranded in .backup/).
1223+
did_remove = False
1224+
if force and self.registry.is_installed(manifest.id):
1225+
# Clear any stale backup from a previous remove so that only the
1226+
# backup produced by the current remove() call is restored later.
1227+
backup_config_dir = self.extensions_dir / ".backup" / manifest.id
1228+
# Check is_symlink first: is_dir() follows symlinks so a
1229+
# symlink-to-directory would pass, but rmtree() raises on them.
1230+
if backup_config_dir.is_symlink():
1231+
backup_config_dir.unlink()
1232+
elif backup_config_dir.is_dir():
1233+
shutil.rmtree(backup_config_dir)
1234+
elif backup_config_dir.exists():
1235+
backup_config_dir.unlink()
1236+
did_remove = self.remove(manifest.id)
1237+
12151238
# Install extension
12161239
dest_dir = self.extensions_dir / manifest.id
12171240
if dest_dir.exists():
@@ -1239,6 +1262,26 @@ def install_from_directory(
12391262
hook_executor = HookExecutor(self.project_root)
12401263
hook_executor.register_hooks(manifest)
12411264

1265+
# Restore config files from backup when --force triggered a removal.
1266+
# Only restore *.yml config files to match what remove() backs up,
1267+
# so unexpected artifacts in .backup/ are not resurrected.
1268+
if did_remove:
1269+
backup_config_dir = self.extensions_dir / ".backup" / manifest.id
1270+
# is_symlink first: is_dir() follows symlinks, but rmtree()
1271+
# raises on them — and we shouldn't follow symlinks to restore.
1272+
if backup_config_dir.is_symlink():
1273+
backup_config_dir.unlink()
1274+
elif backup_config_dir.is_dir():
1275+
for cfg_file in backup_config_dir.iterdir():
1276+
if cfg_file.is_file() and not cfg_file.is_symlink() and (
1277+
cfg_file.name.endswith("-config.yml") or
1278+
cfg_file.name.endswith("-config.local.yml")
1279+
):
1280+
shutil.copy2(cfg_file, dest_dir / cfg_file.name)
1281+
shutil.rmtree(backup_config_dir)
1282+
elif backup_config_dir.exists():
1283+
backup_config_dir.unlink()
1284+
12421285
# Update registry
12431286
self.registry.add(manifest.id, {
12441287
"version": manifest.version,
@@ -1257,13 +1300,16 @@ def install_from_zip(
12571300
zip_path: Path,
12581301
speckit_version: str,
12591302
priority: int = 10,
1303+
force: bool = False,
12601304
) -> ExtensionManifest:
12611305
"""Install extension from ZIP file.
12621306
12631307
Args:
12641308
zip_path: Path to extension ZIP file
12651309
speckit_version: Current spec-kit version
12661310
priority: Resolution priority (lower = higher precedence, default 10)
1311+
force: If True and extension is already installed, remove it first
1312+
before proceeding with installation
12671313
12681314
Returns:
12691315
Installed extension manifest
@@ -1310,7 +1356,9 @@ def install_from_zip(
13101356
raise ValidationError("No extension.yml found in ZIP file")
13111357

13121358
# Install from extracted directory
1313-
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
1359+
return self.install_from_directory(
1360+
extension_dir, speckit_version, priority=priority, force=force
1361+
)
13141362

13151363
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
13161364
"""Remove an installed extension.
@@ -2742,7 +2790,7 @@ def unregister_hooks(self, extension_id: str):
27422790

27432791
if not isinstance(config, dict):
27442792
config = {}
2745-
# We don't save yet, as there are no hooks to unregister,
2793+
# We don't save yet, as there are no hooks to unregister,
27462794
# but unregister_extension above might have already saved a normalized config.
27472795
return
27482796

tests/test_extensions.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,102 @@ def test_install_duplicate(self, extension_dir, project_dir):
793793
with pytest.raises(ExtensionError, match="already installed"):
794794
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
795795

796+
def test_install_force_reinstall(self, extension_dir, project_dir):
797+
"""Test force-reinstalling an already-installed extension."""
798+
manager = ExtensionManager(project_dir)
799+
800+
# Install once
801+
manager.install_from_directory(
802+
extension_dir, "0.1.0", register_commands=False
803+
)
804+
assert manager.registry.is_installed("test-ext")
805+
806+
# Force-reinstall
807+
manifest2 = manager.install_from_directory(
808+
extension_dir, "0.1.0", register_commands=False, force=True
809+
)
810+
811+
assert manifest2.id == "test-ext"
812+
assert manager.registry.is_installed("test-ext")
813+
# Check extension directory was recreated
814+
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
815+
assert ext_dir.exists()
816+
assert (ext_dir / "extension.yml").exists()
817+
assert (ext_dir / "commands" / "hello.md").exists()
818+
819+
def test_install_force_config_preserved(self, extension_dir, project_dir):
820+
"""Test that config files are preserved when force-reinstalling."""
821+
manager = ExtensionManager(project_dir)
822+
823+
# Install once
824+
manager.install_from_directory(
825+
extension_dir, "0.1.0", register_commands=False
826+
)
827+
828+
# Create a config file in the installed extension directory
829+
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
830+
config_file = ext_dir / "test-ext-config.yml"
831+
config_file.write_text("test: config")
832+
833+
# Force-reinstall
834+
manager.install_from_directory(
835+
extension_dir, "0.1.0", register_commands=False, force=True
836+
)
837+
838+
# Config file should still exist after reinstall
839+
new_config = ext_dir / "test-ext-config.yml"
840+
assert new_config.exists()
841+
assert new_config.read_text() == "test: config"
842+
843+
def test_install_force_without_existing(self, extension_dir, project_dir):
844+
"""Test force-install when extension is NOT already installed (works normally)."""
845+
manager = ExtensionManager(project_dir)
846+
847+
manifest = manager.install_from_directory(
848+
extension_dir, "0.1.0", register_commands=False, force=True
849+
)
850+
851+
assert manifest.id == "test-ext"
852+
assert manager.registry.is_installed("test-ext")
853+
854+
def test_install_zip_force_reinstall(self, extension_dir, project_dir):
855+
"""Test force-reinstalling from ZIP when already installed."""
856+
import zipfile
857+
import tempfile
858+
859+
manager = ExtensionManager(project_dir)
860+
861+
# Install once from directory
862+
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
863+
864+
# Create a ZIP of the extension in a temp directory (not NamedTemporaryFile,
865+
# which can fail on Windows due to file locking).
866+
with tempfile.TemporaryDirectory() as tmpdir:
867+
zip_path = Path(tmpdir) / "test-ext.zip"
868+
with zipfile.ZipFile(zip_path, "w") as zf:
869+
for f in extension_dir.rglob("*"):
870+
if f.is_file():
871+
zf.write(f, f.relative_to(extension_dir))
872+
873+
# Force-reinstall from ZIP
874+
manifest = manager.install_from_zip(
875+
zip_path, "0.1.0", force=True
876+
)
877+
878+
assert manifest.id == "test-ext"
879+
assert manager.registry.is_installed("test-ext")
880+
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
881+
assert ext_dir.exists()
882+
883+
def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir):
884+
"""Test that duplicate install error message suggests --force."""
885+
manager = ExtensionManager(project_dir)
886+
887+
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
888+
889+
with pytest.raises(ExtensionError, match="--force"):
890+
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
891+
796892
def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_dir):
797893
"""Install should reject extension IDs that shadow core commands."""
798894
import yaml
@@ -5114,3 +5210,69 @@ def test_non_cline_extension_no_hyphenation(self, tmp_path):
51145210
# Verify body references are still dotted for non-Cline
51155211
assert "speckit.mock-ext.greet" in hello_body
51165212
assert "speckit-mock-ext-greet" not in hello_body
5213+
5214+
5215+
class TestExtensionForceCLI:
5216+
"""CLI tests for `specify extension add --dev --force`."""
5217+
5218+
def _create_minimal_extension(self, base_dir: str | Path, ext_id: str = "test-ext") -> Path:
5219+
"""Create a minimal extension directory with manifest."""
5220+
import yaml
5221+
5222+
ext_dir = Path(base_dir) / ext_id
5223+
ext_dir.mkdir(parents=True, exist_ok=True)
5224+
(ext_dir / "commands").mkdir()
5225+
5226+
manifest = {
5227+
"schema_version": "1.0",
5228+
"extension": {
5229+
"id": ext_id,
5230+
"name": "Test Extension",
5231+
"version": "1.0.0",
5232+
"description": "Test",
5233+
},
5234+
"requires": {"speckit_version": ">=0.1.0"},
5235+
"provides": {
5236+
"commands": [
5237+
{
5238+
"name": f"speckit.{ext_id}.hello",
5239+
"file": "commands/hello.md",
5240+
"description": "Test command",
5241+
}
5242+
]
5243+
},
5244+
}
5245+
5246+
(ext_dir / "extension.yml").write_text(yaml.dump(manifest))
5247+
(ext_dir / "commands" / "hello.md").write_text(
5248+
"---\ndescription: Test\n---\n\nHello $ARGUMENTS\n"
5249+
)
5250+
return ext_dir
5251+
5252+
def test_add_dev_force_reinstall(self, tmp_path):
5253+
"""extension add --dev --force should reinstall without error."""
5254+
from typer.testing import CliRunner
5255+
from unittest.mock import patch
5256+
from specify_cli import app
5257+
5258+
project_dir = tmp_path / "project"
5259+
project_dir.mkdir()
5260+
(project_dir / ".specify").mkdir()
5261+
5262+
ext_src = self._create_minimal_extension(tmp_path)
5263+
5264+
runner = CliRunner()
5265+
with patch.object(Path, "cwd", return_value=project_dir):
5266+
# First install
5267+
result1 = runner.invoke(
5268+
app, ["extension", "add", str(ext_src), "--dev"], catch_exceptions=False
5269+
)
5270+
assert result1.exit_code == 0, strip_ansi(result1.output)
5271+
assert "installed" in strip_ansi(result1.output)
5272+
5273+
# Force reinstall
5274+
result2 = runner.invoke(
5275+
app, ["extension", "add", str(ext_src), "--dev", "--force"], catch_exceptions=False
5276+
)
5277+
assert result2.exit_code == 0, strip_ansi(result2.output)
5278+
assert "installed" in strip_ansi(result2.output)

0 commit comments

Comments
 (0)