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.
This commit is contained in:
Grissiom.GuRui
2026-06-04 05:34:24 +08:00
committed by GitHub
parent df09fd49c6
commit 659a41a6cc
3 changed files with 232 additions and 10 deletions

View File

@@ -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")
# 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] Installing from [cyan]{source_path}[/cyan] (will overwrite if already installed)...")
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():