Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1395,13 +1395,52 @@ def install_from_directory(
backup_config_dir.unlink()
did_remove = self.remove(manifest.id)

# A prior `remove(keep_config=True)` leaves the top-level
# *-config.yml / *-config.local.yml in place while dropping the
# registry entry. A later reinstall is not a --force removal
# (did_remove is False), so the rmtree below would wipe those
# preserved configs and the backup-restore path never runs. Capture
# them here and write them back after the fresh copytree, mirroring
# the *-config filter the --force restore path uses.
# Capture (bytes, mode) so we can restore permissions too — config
# files may hold secrets (API keys), and recreating them with default
# perms could widen access. Mirrors the --force restore's shutil.copy2
# (which preserves mode/mtime).
preserved_configs: dict[str, tuple[bytes, int]] = {}
if dest_dir.exists():
for cfg_file in dest_dir.iterdir():
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")
)
):
preserved_configs[cfg_file.name] = (
cfg_file.read_bytes(),
cfg_file.stat().st_mode,
)

# Install extension (dest_dir computed above during self-install guard)
if dest_dir.exists():
shutil.rmtree(dest_dir)

ignore_fn = self._load_extensionignore(source_dir)
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)

# Restore configs preserved from a keep-config leftover (see above),
# preserving the original file mode. The --force backup-restore below
# (did_remove) still takes precedence for that path, which uses
# .backup/ rather than an in-place leftover.
for name, (data, mode) in preserved_configs.items():
dest_cfg = dest_dir / name
dest_cfg.write_bytes(data)
try:
os.chmod(dest_cfg, mode & 0o7777) # permission bits only
except OSError:
pass

# Register commands with AI agents
registered_commands = {}
if register_commands:
Expand Down
29 changes: 29 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,35 @@ def test_config_backup_on_remove(self, extension_dir, project_dir):
assert backup_file.exists()
assert backup_file.read_text() == "test: config"

def test_reinstall_preserves_keep_config_leftover(self, extension_dir, project_dir):
"""A reinstall after `remove(keep_config=True)` must not wipe the
preserved config. remove(keep_config=True) leaves the *-config.yml in
place and drops the registry entry; the subsequent reinstall is not a
--force removal, so the unconditional rmtree used to destroy it."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)

ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("api_key: MY-CUSTOMIZED-VALUE")
# Restrictive perms — a config may hold secrets; the restore must not
# widen them (POSIX only; Windows chmod ignores group/other bits).
config_file.chmod(0o600)

# keep-config removal: registry entry dropped, config left in place.
assert manager.remove("test-ext", keep_config=True) is True
assert config_file.exists()

# Reinstall (no --force; registry now reports not-installed).
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)

# The user's config must survive the reinstall (was silently wiped).
assert config_file.exists()
assert "MY-CUSTOMIZED-VALUE" in config_file.read_text()
# ...and keep its original (restrictive) mode, not default perms.
if platform.system() != "Windows":
assert config_file.stat().st_mode & 0o777 == 0o600


# ===== CommandRegistrar Tests =====

Expand Down