Skip to content

fix: prevent extension self-install from deleting source dir (#2990)#2991

Open
jinaparkdev wants to merge 1 commit into
github:mainfrom
jinaparkdev:fix/2990-extension-self-install-guard
Open

fix: prevent extension self-install from deleting source dir (#2990)#2991
jinaparkdev wants to merge 1 commit into
github:mainfrom
jinaparkdev:fix/2990-extension-self-install-guard

Conversation

@jinaparkdev

@jinaparkdev jinaparkdev commented Jun 16, 2026

Copy link
Copy Markdown

specify extension add <path> --dev --force permanently deleted the extension directory without registering it when the source path resolved to the extension's own install location
(.specify/extensions/<id>).

With --force, install_from_directory() removed the existing installation (the source) and then shutil.copytree() tried to copy from the now-deleted directory, destroying it
and crashing.

Add a guard that fails fast with a clear ValidationError when the resolved source path equals the install destination, before any destructive operation runs. Includes a regression
test asserting the directory and its contents survive.

Description

When the source path passed to extension add --dev --force resolves to the extension's own install destination, the old flow removed the source and then copied from a directory
that no longer existed — destroying the extension with no registration. This PR adds an early source == destination check in ExtensionManager.install_from_directory()
(src/specify_cli/extensions.py) that raises a clear ValidationError before any removal/copy happens, so the directory is never touched on the error path.

Testing

  • Tested locally with uv run specify --help
  • Ran existing tests with uv sync && uv run pytest
  • Tested with a sample project (if applicable)

Manual end-to-end on macOS/zsh:

Scenario Result
extension add .specify/extensions/<id> --dev --force (self-install) — with fix ✅ Clear error, directory + extension.yml + commands left intact
Same command without fix (reverted) Reproduced original bug: FileNotFoundError, directory destroyed
extension add /other/path --dev --force (different location) ✅ Still installs — no regression

Added regression test test_install_from_install_dir_is_rejected_without_data_loss; full suite tests/test_extensions.py → 283 passed.

AI Disclosure

  • I did not use AI assistance for this contribution
  • I did use AI assistance (describe below)

Developed with Claude Code (AI). The AI located the root cause, wrote the guard and the regression test. I reviewed every change, reproduced the original data-loss bug locally, and
verified the fix end-to-end myself before submitting.

…2990)

`specify extension add <path> --dev --force` permanently deleted the
extension directory without registering it when the source path resolved
to the extension's own install location (`.specify/extensions/<id>`).

With `--force`, `install_from_directory()` removed the existing
installation (the source) and then `shutil.copytree()` tried to copy from
the now-deleted directory, destroying it and crashing.

Add a guard that fails fast with a clear ValidationError when the resolved
source path equals the install destination, before any destructive
operation runs. Includes a regression test asserting the directory and its
contents survive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a data-loss edge case in the extension installation flow where specify extension add <path> --dev --force could delete the extension directory if the provided source path resolved to the extension’s own install destination (.specify/extensions/<id>). It adds an early guard to reject self-installs before any destructive operations, and includes a regression test to ensure the directory contents remain intact.

Changes:

  • Add a fast-fail guard in ExtensionManager.install_from_directory() to reject installing from the computed install destination path.
  • Reuse the computed dest_dir later in the install flow to avoid recomputation and keep the guard aligned with the actual install target.
  • Add a regression test asserting self-install attempts with --force raise a ValidationError without deleting the extension directory or its files.
Show a summary per file
File Description
src/specify_cli/extensions.py Adds a self-install safety guard before --force removal/copy to prevent deleting the source directory.
tests/test_extensions.py Adds a regression test verifying self-install is rejected and the installed extension directory remains intact.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 1

Comment on lines +1339 to +1342
try:
same_location = source_dir.resolve() == dest_dir.resolve()
except OSError:
same_location = False
@mnriem

mnriem commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants