From 0b244a8c0c5a309f51e5619b70e61a9fd919c63a Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Thu, 7 May 2026 15:14:35 +0000 Subject: [PATCH 01/30] chore: update community catalog with latest extension versions - Update memory-md from 0.7.9 to 0.8.0 - Update architecture-guard from 1.6.7 to 1.8.0 --- extensions/catalog.community.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index b9d72ce6e4..affac537f5 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -174,8 +174,8 @@ "id": "architecture-guard", "description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.", "author": "DyanGalih", - "version": "1.6.7", - "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.6.7.zip", + "version": "1.8.0", + "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.0.zip", "repository": "https://github.com/DyanGalih/spec-kit-architecture-guard", "homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard", "documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md", @@ -200,7 +200,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-05-05T07:26:00Z", - "updated_at": "2026-05-06T22:28:55Z" + "updated_at": "2026-05-07T15:37:14Z" }, "archive": { "name": "Archive Extension", @@ -1452,8 +1452,8 @@ "id": "memory-md", "description": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context", "author": "DyanGalih", - "version": "0.7.9", - "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.9.zip", + "version": "0.8.0", + "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.8.0.zip", "repository": "https://github.com/DyanGalih/spec-kit-memory-hub", "homepage": "https://github.com/DyanGalih/spec-kit-memory-hub", "documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md", @@ -1478,7 +1478,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-23T00:00:00Z", - "updated_at": "2026-05-06T22:28:55Z" + "updated_at": "2026-05-07T15:37:14Z" }, "memorylint": { "name": "MemoryLint", From 730918eb12dc8f962e11bd16cffa406c444a4c55 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 8 May 2026 10:27:03 +0000 Subject: [PATCH 02/30] fix(cli): harden extension registration with project-level tracking in extensions.yml --- src/specify_cli/extensions.py | 37 ++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 944ee4a06d..88b835f17e 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1190,7 +1190,7 @@ def install_from_directory( # was used during project initialisation (feature parity). registered_skills = self._register_extension_skills(manifest, dest_dir) - # Register hooks + # Register hooks and update installed list in extensions.yml hook_executor = HookExecutor(self.project_root) hook_executor.register_hooks(manifest) @@ -2501,12 +2501,44 @@ def save_project_config(self, config: Dict[str, Any]): encoding="utf-8", ) + def register_extension(self, extension_id: str): + """Add extension to the installed list in project config. + + Args: + extension_id: ID of extension to register + """ + config = self.get_project_config() + + if "installed" not in config: + config["installed"] = [] + + if extension_id not in config["installed"]: + config["installed"].append(extension_id) + # Maintain alphabetical order for readability and diff stability + config["installed"].sort() + self.save_project_config(config) + + def unregister_extension(self, extension_id: str): + """Remove extension from the installed list in project config. + + Args: + extension_id: ID of extension to unregister + """ + config = self.get_project_config() + + if "installed" in config and extension_id in config["installed"]: + config["installed"].remove(extension_id) + self.save_project_config(config) + def register_hooks(self, manifest: ExtensionManifest): """Register extension hooks in project config. Args: manifest: Extension manifest with hooks to register """ + # Always ensure the extension is in the installed list + self.register_extension(manifest.id) + if not hasattr(manifest, "hooks") or not manifest.hooks: return @@ -2577,6 +2609,9 @@ def unregister_hooks(self, extension_id: str): self.save_project_config(config) + # Also remove from installed list + self.unregister_extension(extension_id) + def get_hooks_for_event(self, event_name: str) -> List[Dict[str, Any]]: """Get all registered hooks for a specific event. From c9531401652eb29237da902f668b64dbb6b2d4cd Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 8 May 2026 10:29:23 +0000 Subject: [PATCH 03/30] test(cli): add comprehensive unit tests for extension registration logic --- tests/test_extension_registration.py | 114 +++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/test_extension_registration.py diff --git a/tests/test_extension_registration.py b/tests/test_extension_registration.py new file mode 100644 index 0000000000..e51570fbdd --- /dev/null +++ b/tests/test_extension_registration.py @@ -0,0 +1,114 @@ +import pytest +import yaml +from pathlib import Path +from specify_cli.extensions import HookExecutor, ExtensionManifest + +@pytest.fixture +def project_dir(tmp_path): + """Create a mock spec-kit project directory.""" + proj_dir = tmp_path / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + return proj_dir + +class TestExtensionRegistration: + """Tests for the 'installed' list management in HookExecutor.""" + + def test_register_extension_new(self, project_dir): + """Standard registration: Adding an extension should add it to the list.""" + executor = HookExecutor(project_dir) + executor.register_extension("test-ext") + + config = executor.get_project_config() + assert "installed" in config + assert config["installed"] == ["test-ext"] + + def test_register_extension_sorting(self, project_dir): + """Order Stability: Extensions should be stored in alphabetical order.""" + executor = HookExecutor(project_dir) + executor.register_extension("zebra-ext") + executor.register_extension("apple-ext") + executor.register_extension("middle-ext") + + config = executor.get_project_config() + assert config["installed"] == ["apple-ext", "middle-ext", "zebra-ext"] + + def test_register_extension_idempotency(self, project_dir): + """Idempotency: Adding the same extension twice should not result in duplicates.""" + executor = HookExecutor(project_dir) + executor.register_extension("test-ext") + executor.register_extension("test-ext") + + config = executor.get_project_config() + assert config["installed"] == ["test-ext"] + assert len(config["installed"]) == 1 + + def test_unregister_extension(self, project_dir): + """Standard unregistration: Removing an extension should prune it from the list.""" + executor = HookExecutor(project_dir) + executor.register_extension("ext-1") + executor.register_extension("ext-2") + + executor.unregister_extension("ext-1") + + config = executor.get_project_config() + assert config["installed"] == ["ext-2"] + + def test_unregister_extension_not_present(self, project_dir): + """Safe Removal: Unregistering a non-existent extension should do nothing.""" + executor = HookExecutor(project_dir) + executor.register_extension("ext-1") + + # Should not raise or change the list + executor.unregister_extension("ext-nonexistent") + + config = executor.get_project_config() + assert config["installed"] == ["ext-1"] + + def test_register_hooks_triggers_registration(self, project_dir, tmp_path): + """Full Workflow: register_hooks should automatically register the extension.""" + # Create a mock manifest + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "hook-ext", + "name": "Hook Ext", + "version": "1.0.0", + "description": "Test", + }, + "requires": { + "speckit_version": ">=0.1.0", + "commands": [] + }, + "provides": {"commands": []}, + "hooks": { + "after_tasks": {"command": "speckit.hook-ext.run"} + } + } + manifest_path = tmp_path / "extension.yml" + with open(manifest_path, "w") as f: + yaml.dump(manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + executor = HookExecutor(project_dir) + + # This should call register_extension internally + executor.register_hooks(manifest) + + config = executor.get_project_config() + assert "hook-ext" in config["installed"] + + def test_missing_installed_key_initialization(self, project_dir): + """Graceful Initialization: If 'installed' key is missing, it should be created.""" + executor = HookExecutor(project_dir) + + # Manually create a config without 'installed' + config_path = project_dir / ".specify" / "extensions.yml" + config_path.write_text(yaml.dump({"settings": {"auto_execute_hooks": True}})) + + # This should detect the missing key and initialize it + executor.register_extension("new-ext") + + config = executor.get_project_config() + assert "installed" in config + assert config["installed"] == ["new-ext"] From 4af789a0f70d7d58411d4e5b652de32ef1500fec Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 8 May 2026 10:45:31 +0000 Subject: [PATCH 04/30] chore: remove out-of-scope catalog changes --- extensions/catalog.community.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index affac537f5..b9d72ce6e4 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -174,8 +174,8 @@ "id": "architecture-guard", "description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.", "author": "DyanGalih", - "version": "1.8.0", - "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.0.zip", + "version": "1.6.7", + "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.6.7.zip", "repository": "https://github.com/DyanGalih/spec-kit-architecture-guard", "homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard", "documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md", @@ -200,7 +200,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-05-05T07:26:00Z", - "updated_at": "2026-05-07T15:37:14Z" + "updated_at": "2026-05-06T22:28:55Z" }, "archive": { "name": "Archive Extension", @@ -1452,8 +1452,8 @@ "id": "memory-md", "description": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context", "author": "DyanGalih", - "version": "0.8.0", - "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.8.0.zip", + "version": "0.7.9", + "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.9.zip", "repository": "https://github.com/DyanGalih/spec-kit-memory-hub", "homepage": "https://github.com/DyanGalih/spec-kit-memory-hub", "documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md", @@ -1478,7 +1478,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-23T00:00:00Z", - "updated_at": "2026-05-07T15:37:14Z" + "updated_at": "2026-05-06T22:28:55Z" }, "memorylint": { "name": "MemoryLint", From 0fd9a1440acb7709c02935ab66a7dce30114474a Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 8 May 2026 10:48:31 +0000 Subject: [PATCH 05/30] refactor: address PR feedback for extension registration hardening --- src/specify_cli/extensions.py | 28 ++++++++++++++++++++-------- tests/test_extension_registration.py | 1 - 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 88b835f17e..6d09f91f90 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2509,7 +2509,12 @@ def register_extension(self, extension_id: str): """ config = self.get_project_config() - if "installed" not in config: + # Ensure config is a dict (defensive) + if not isinstance(config, dict): + config = {} + + # Ensure "installed" is a list (defensive) + if "installed" not in config or not isinstance(config["installed"], list): config["installed"] = [] if extension_id not in config["installed"]: @@ -2526,7 +2531,14 @@ def unregister_extension(self, extension_id: str): """ config = self.get_project_config() - if "installed" in config and extension_id in config["installed"]: + if not isinstance(config, dict): + return + + if ( + "installed" in config + and isinstance(config["installed"], list) + and extension_id in config["installed"] + ): config["installed"].remove(extension_id) self.save_project_config(config) @@ -2589,17 +2601,20 @@ def unregister_hooks(self, extension_id: str): Args: extension_id: ID of extension to unregister """ + # Always remove from installed list (Feedback from review) + self.unregister_extension(extension_id) + config = self.get_project_config() - if "hooks" not in config: + if "hooks" not in config or not isinstance(config["hooks"], dict): return # Remove hooks for this extension - for hook_name in config["hooks"]: + for hook_name in list(config["hooks"].keys()): config["hooks"][hook_name] = [ h for h in config["hooks"][hook_name] - if h.get("extension") != extension_id + if isinstance(h, dict) and h.get("extension") != extension_id ] # Clean up empty hook arrays @@ -2609,9 +2624,6 @@ def unregister_hooks(self, extension_id: str): self.save_project_config(config) - # Also remove from installed list - self.unregister_extension(extension_id) - def get_hooks_for_event(self, event_name: str) -> List[Dict[str, Any]]: """Get all registered hooks for a specific event. diff --git a/tests/test_extension_registration.py b/tests/test_extension_registration.py index e51570fbdd..1ad92b7e63 100644 --- a/tests/test_extension_registration.py +++ b/tests/test_extension_registration.py @@ -1,6 +1,5 @@ import pytest import yaml -from pathlib import Path from specify_cli.extensions import HookExecutor, ExtensionManifest @pytest.fixture From 019acaed6e9f3fea09e43c452e522e48c29b1d71 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 8 May 2026 11:27:00 +0000 Subject: [PATCH 06/30] fix: harden extension registration defensive logic and add comprehensive unregister_hooks tests - Add dict guard to register_hooks() to handle corrupted extensions.yml (non-dict root) - Add 5 comprehensive tests for unregister_hooks() workflow: * Full workflow with hooks + installed list removal * Resilience when config has no 'hooks' key * Corrupted YAML handling * Multiple extension scenarios * All 11 tests passing --- src/specify_cli/extensions.py | 4 + tests/test_extension_registration.py | 144 +++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 6d09f91f90..5f805fbd2c 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2556,6 +2556,10 @@ def register_hooks(self, manifest: ExtensionManifest): config = self.get_project_config() + # Ensure config is a dict (defensive) + if not isinstance(config, dict): + config = {} + # Ensure hooks dict exists if "hooks" not in config: config["hooks"] = {} diff --git a/tests/test_extension_registration.py b/tests/test_extension_registration.py index 1ad92b7e63..42ea3a4ce1 100644 --- a/tests/test_extension_registration.py +++ b/tests/test_extension_registration.py @@ -111,3 +111,147 @@ def test_missing_installed_key_initialization(self, project_dir): config = executor.get_project_config() assert "installed" in config assert config["installed"] == ["new-ext"] + + def test_unregister_hooks_full_workflow(self, project_dir, tmp_path): + """Full Workflow: unregister_hooks should remove hooks and prune installed list.""" + # Create a manifest with hooks + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "hook-ext", + "name": "Hook Ext", + "version": "1.0.0", + "description": "Test", + }, + "requires": { + "speckit_version": ">=0.1.0", + "commands": [] + }, + "provides": {"commands": []}, + "hooks": { + "after_tasks": {"command": "speckit.hook-ext.run"} + } + } + manifest_path = tmp_path / "extension.yml" + with open(manifest_path, "w") as f: + yaml.dump(manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + executor = HookExecutor(project_dir) + + # Register hooks first + executor.register_hooks(manifest) + + config = executor.get_project_config() + assert "hook-ext" in config["installed"] + assert "after_tasks" in config["hooks"] + + # Now unregister hooks + executor.unregister_hooks("hook-ext") + + config = executor.get_project_config() + assert "hook-ext" not in config["installed"] + # Hook entry should be removed from hooks dict + if "after_tasks" in config["hooks"]: + assert len(config["hooks"]["after_tasks"]) == 0 + + def test_unregister_hooks_no_hooks_key(self, project_dir): + """Resilience: unregister_hooks should work even if config has no 'hooks' key.""" + executor = HookExecutor(project_dir) + + # Register extension without hooks + executor.register_extension("ext-no-hooks") + + config = executor.get_project_config() + assert "ext-no-hooks" in config["installed"] + + # Unregister should not crash even if no hooks key exists + executor.unregister_hooks("ext-no-hooks") + + config = executor.get_project_config() + assert "ext-no-hooks" not in config["installed"] + + def test_unregister_hooks_corrupted_config(self, project_dir): + """Resilience: unregister_hooks should gracefully handle corrupted config.""" + # Create a corrupted config (root is a list) + config_path = project_dir / ".specify" / "extensions.yml" + config_path.write_text(yaml.dump(["corrupted", "list"])) + + executor = HookExecutor(project_dir) + + # Should not raise even with corrupted config + executor.unregister_hooks("non-existent") + + # Config should remain as-is or be handled gracefully + config = executor.get_project_config() + # If it's corrupted, it's returned as-is or handled by defensive logic + assert config is not None + + def test_unregister_hooks_with_multiple_extensions(self, project_dir, tmp_path): + """Multiple Extensions: unregister_hooks should only remove target extension's hooks.""" + # Create two manifests + manifest_data_1 = { + "schema_version": "1.0", + "extension": { + "id": "ext-1", + "name": "Ext 1", + "version": "1.0.0", + "description": "Test 1", + }, + "requires": { + "speckit_version": ">=0.1.0", + "commands": [] + }, + "provides": {"commands": []}, + "hooks": { + "after_tasks": {"command": "speckit.ext-1.run"} + } + } + manifest_data_2 = { + "schema_version": "1.0", + "extension": { + "id": "ext-2", + "name": "Ext 2", + "version": "1.0.0", + "description": "Test 2", + }, + "requires": { + "speckit_version": ">=0.1.0", + "commands": [] + }, + "provides": {"commands": []}, + "hooks": { + "after_tasks": {"command": "speckit.ext-2.run"} + } + } + + manifest_path_1 = tmp_path / "extension1.yml" + manifest_path_2 = tmp_path / "extension2.yml" + with open(manifest_path_1, "w") as f: + yaml.dump(manifest_data_1, f) + with open(manifest_path_2, "w") as f: + yaml.dump(manifest_data_2, f) + + manifest1 = ExtensionManifest(manifest_path_1) + manifest2 = ExtensionManifest(manifest_path_2) + executor = HookExecutor(project_dir) + + # Register both extensions + executor.register_hooks(manifest1) + executor.register_hooks(manifest2) + + config = executor.get_project_config() + assert "ext-1" in config["installed"] + assert "ext-2" in config["installed"] + assert len(config["hooks"]["after_tasks"]) == 2 + + # Unregister first extension + executor.unregister_hooks("ext-1") + + config = executor.get_project_config() + assert "ext-1" not in config["installed"] + assert "ext-2" in config["installed"] + # ext-2's hook should still be there + assert len(config["hooks"]["after_tasks"]) == 1 + assert config["hooks"]["after_tasks"][0].get("extension") == "ext-2" + From 51d19cedb325dab80b72713a368d3d0201e13183 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 8 May 2026 12:09:05 +0000 Subject: [PATCH 07/30] fix: sanitize installed to strings, guard unregister_hooks dict, handle null hook values - register_extension(): filter non-string entries from installed before sort - register_hooks(): normalize hooks to {} when missing or not a dict - unregister_hooks(): add isinstance(config, dict) guard before key checks - unregister_hooks(): coerce null/scalar hook lists to [] before iteration - tests: add 3 regression tests for no-hooks manifest, mixed-type installed, null hook values - All 14 tests passing --- src/specify_cli/extensions.py | 18 ++++++-- tests/test_extension_registration.py | 62 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 5f805fbd2c..878a81af72 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2513,9 +2513,12 @@ def register_extension(self, extension_id: str): if not isinstance(config, dict): config = {} - # Ensure "installed" is a list (defensive) + # Ensure "installed" is a list of strings (defensive) if "installed" not in config or not isinstance(config["installed"], list): config["installed"] = [] + else: + # Sanitize: keep only strings to prevent TypeError on sort + config["installed"] = [x for x in config["installed"] if isinstance(x, str)] if extension_id not in config["installed"]: config["installed"].append(extension_id) @@ -2560,8 +2563,8 @@ def register_hooks(self, manifest: ExtensionManifest): if not isinstance(config, dict): config = {} - # Ensure hooks dict exists - if "hooks" not in config: + # Ensure hooks dict exists and is a mapping + if "hooks" not in config or not isinstance(config["hooks"], dict): config["hooks"] = {} # Register each hook @@ -2610,14 +2613,21 @@ def unregister_hooks(self, extension_id: str): config = self.get_project_config() + if not isinstance(config, dict): + return + if "hooks" not in config or not isinstance(config["hooks"], dict): return # Remove hooks for this extension for hook_name in list(config["hooks"].keys()): + hook_list = config["hooks"][hook_name] + if not isinstance(hook_list, list): + config["hooks"][hook_name] = [] + continue config["hooks"][hook_name] = [ h - for h in config["hooks"][hook_name] + for h in hook_list if isinstance(h, dict) and h.get("extension") != extension_id ] diff --git a/tests/test_extension_registration.py b/tests/test_extension_registration.py index 42ea3a4ce1..46c719610a 100644 --- a/tests/test_extension_registration.py +++ b/tests/test_extension_registration.py @@ -255,3 +255,65 @@ def test_unregister_hooks_with_multiple_extensions(self, project_dir, tmp_path): assert len(config["hooks"]["after_tasks"]) == 1 assert config["hooks"]["after_tasks"][0].get("extension") == "ext-2" + def test_register_hooks_no_hooks_still_registers(self, project_dir, tmp_path): + """Commands-only manifest: register_hooks() must still update installed even with no hooks.""" + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "commands-only-ext", + "name": "Commands Only", + "version": "1.0.0", + "description": "No hooks, only commands", + }, + "requires": { + "speckit_version": ">=0.1.0", + "commands": [] + }, + "provides": {"commands": [{"name": "speckit.commands-only-ext.run", "file": "commands/run.md"}]}, + } + manifest_path = tmp_path / "extension.yml" + with open(manifest_path, "w") as f: + yaml.dump(manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + executor = HookExecutor(project_dir) + executor.register_hooks(manifest) + + config = executor.get_project_config() + assert "commands-only-ext" in config["installed"] + + def test_register_extension_mixed_type_installed(self, project_dir): + """Regression: installed list with non-string entries must not crash on sort.""" + executor = HookExecutor(project_dir) + + # Manually write a corrupted installed list with non-string entries + config_path = project_dir / ".specify" / "extensions.yml" + config_path.write_text(yaml.dump({"installed": [1, True, "existing-ext"]})) + + # Should not raise TypeError on sort + executor.register_extension("new-ext") + + config = executor.get_project_config() + # Non-string entries are dropped; valid strings are preserved + assert "existing-ext" in config["installed"] + assert "new-ext" in config["installed"] + assert 1 not in config["installed"] + assert True not in config["installed"] + + def test_unregister_hooks_null_hook_values(self, project_dir): + """Regression: hooks: {after_tasks: null} must not crash in unregister_hooks().""" + executor = HookExecutor(project_dir) + + # Manually write a config with null hook event value + config_path = project_dir / ".specify" / "extensions.yml" + config_path.write_text(yaml.dump({ + "installed": ["broken-ext"], + "hooks": {"after_tasks": None} + })) + + # Should not raise TypeError when iterating None + executor.unregister_hooks("broken-ext") + + config = executor.get_project_config() + assert "broken-ext" not in config["installed"] + From ba3423757555401b26acd01ef7632d4713e07f52 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 11 May 2026 15:04:32 +0000 Subject: [PATCH 08/30] fix(cli): persist sanitization results and harden hook registration --- src/specify_cli/extensions.py | 78 ++++++++++++++++++---------- tests/test_extension_registration.py | 41 +++++++++++++++ 2 files changed, 91 insertions(+), 28 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 878a81af72..cc1479e082 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2513,17 +2513,24 @@ def register_extension(self, extension_id: str): if not isinstance(config, dict): config = {} - # Ensure "installed" is a list of strings (defensive) - if "installed" not in config or not isinstance(config["installed"], list): - config["installed"] = [] - else: - # Sanitize: keep only strings to prevent TypeError on sort - config["installed"] = [x for x in config["installed"] if isinstance(x, str)] - - if extension_id not in config["installed"]: - config["installed"].append(extension_id) + # Load and sanitize existing installed list + installed = config.get("installed") + if not isinstance(installed, list): + installed = [] + + sanitized = [x for x in installed if isinstance(x, str)] + + # We need to save if we dropped non-string entries or if the ID is new + changed = len(sanitized) != len(installed) + + if extension_id not in sanitized: + sanitized.append(extension_id) # Maintain alphabetical order for readability and diff stability - config["installed"].sort() + sanitized.sort() + changed = True + + if changed: + config["installed"] = sanitized self.save_project_config(config) def unregister_extension(self, extension_id: str): @@ -2537,12 +2544,21 @@ def unregister_extension(self, extension_id: str): if not isinstance(config, dict): return - if ( - "installed" in config - and isinstance(config["installed"], list) - and extension_id in config["installed"] - ): - config["installed"].remove(extension_id) + installed = config.get("installed") + if not isinstance(installed, list): + return + + sanitized = [x for x in installed if isinstance(x, str)] + + # Save if we drop non-strings or if we remove the extension + changed = len(sanitized) != len(installed) + + if extension_id in sanitized: + sanitized.remove(extension_id) + changed = True + + if changed: + config["installed"] = sanitized self.save_project_config(config) def register_hooks(self, manifest: ExtensionManifest): @@ -2560,17 +2576,21 @@ def register_hooks(self, manifest: ExtensionManifest): config = self.get_project_config() # Ensure config is a dict (defensive) + changed = False if not isinstance(config, dict): config = {} + changed = True # Ensure hooks dict exists and is a mapping if "hooks" not in config or not isinstance(config["hooks"], dict): config["hooks"] = {} + changed = True # Register each hook for hook_name, hook_config in manifest.hooks.items(): - if hook_name not in config["hooks"]: + if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list): config["hooks"][hook_name] = [] + changed = True # Add hook entry hook_entry = { @@ -2586,21 +2606,23 @@ def register_hooks(self, manifest: ExtensionManifest): } # Check if already registered - existing = [ - h - for h in config["hooks"][hook_name] - if h.get("extension") == manifest.id - ] + existing_idx = -1 + for i, h in enumerate(config["hooks"][hook_name]): + if isinstance(h, dict) and h.get("extension") == manifest.id: + existing_idx = i + break - if not existing: + if existing_idx == -1: config["hooks"][hook_name].append(hook_entry) + changed = True else: - # Update existing - for i, h in enumerate(config["hooks"][hook_name]): - if h.get("extension") == manifest.id: - config["hooks"][hook_name][i] = hook_entry + # Update existing if changed + if config["hooks"][hook_name][existing_idx] != hook_entry: + config["hooks"][hook_name][existing_idx] = hook_entry + changed = True - self.save_project_config(config) + if changed: + self.save_project_config(config) def unregister_hooks(self, extension_id: str): """Remove extension hooks from project config. diff --git a/tests/test_extension_registration.py b/tests/test_extension_registration.py index 46c719610a..440ad1a282 100644 --- a/tests/test_extension_registration.py +++ b/tests/test_extension_registration.py @@ -317,3 +317,44 @@ def test_unregister_hooks_null_hook_values(self, project_dir): config = executor.get_project_config() assert "broken-ext" not in config["installed"] + def test_register_hooks_corrupted_hook_values(self, project_dir, tmp_path): + """Regression: register_hooks() must handle non-list hook event values in config.""" + executor = HookExecutor(project_dir) + + # Manually write a config with null hook event value + config_path = project_dir / ".specify" / "extensions.yml" + config_path.write_text(yaml.dump({ + "installed": ["some-ext"], + "hooks": {"after_tasks": None} + })) + + # Create a manifest with a hook for the same event + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "new-ext", + "name": "New Ext", + "version": "1.0.0", + "description": "Test", + }, + "requires": { + "speckit_version": ">=0.1.0", + "commands": [] + }, + "provides": {"commands": []}, + "hooks": {"after_tasks": {"command": "speckit.new-ext.run"}} + } + manifest_path = tmp_path / "extension.yml" + with open(manifest_path, "w") as f: + yaml.dump(manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + # Should not raise TypeError when trying to append to None + executor.register_hooks(manifest) + + config = executor.get_project_config() + assert "new-ext" in config["installed"] + assert isinstance(config["hooks"]["after_tasks"], list) + assert any(h["extension"] == "new-ext" for h in config["hooks"]["after_tasks"]) + From f7785b58e632cafd7430012a836be09f2fe546ed Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 11 May 2026 15:39:30 +0000 Subject: [PATCH 09/30] Harden extension registration to always persist sanitization results --- src/specify_cli/extensions.py | 28 ++++++++++++++++------------ tests/test_extension_registration.py | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index cc1479e082..1d0ad96b8d 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2518,18 +2518,19 @@ def register_extension(self, extension_id: str): if not isinstance(installed, list): installed = [] + # Capture original state to check for changes (Feedback from review) + original_installed = list(installed) + + # Sanitize: keep only strings sanitized = [x for x in installed if isinstance(x, str)] - # We need to save if we dropped non-string entries or if the ID is new - changed = len(sanitized) != len(installed) - if extension_id not in sanitized: sanitized.append(extension_id) - # Maintain alphabetical order for readability and diff stability - sanitized.sort() - changed = True - if changed: + # Maintain alphabetical order for readability and diff stability + sanitized.sort() + + if sanitized != original_installed: config["installed"] = sanitized self.save_project_config(config) @@ -2548,16 +2549,19 @@ def unregister_extension(self, extension_id: str): if not isinstance(installed, list): return + # Capture original state to check for changes (Feedback from review) + original_installed = list(installed) + + # Sanitize and remove sanitized = [x for x in installed if isinstance(x, str)] - # Save if we drop non-strings or if we remove the extension - changed = len(sanitized) != len(installed) - if extension_id in sanitized: sanitized.remove(extension_id) - changed = True - if changed: + # Maintain alphabetical order for consistency + sanitized.sort() + + if sanitized != original_installed: config["installed"] = sanitized self.save_project_config(config) diff --git a/tests/test_extension_registration.py b/tests/test_extension_registration.py index 440ad1a282..fc5a1404f7 100644 --- a/tests/test_extension_registration.py +++ b/tests/test_extension_registration.py @@ -358,3 +358,20 @@ def test_register_hooks_corrupted_hook_values(self, project_dir, tmp_path): assert isinstance(config["hooks"]["after_tasks"], list) assert any(h["extension"] == "new-ext" for h in config["hooks"]["after_tasks"]) + def test_register_extension_already_present_in_corrupted_list(self, project_dir): + """Regression: if extension is already present but list has non-strings, it must still be sanitized.""" + executor = HookExecutor(project_dir) + + # Extension is present, but list has garbage + config_path = project_dir / ".specify" / "extensions.yml" + config_path.write_text(yaml.dump({"installed": [1, "test-ext", True]})) + + # This should trigger sanitization and save, even though "test-ext" is already there + executor.register_extension("test-ext") + + config = executor.get_project_config() + assert config["installed"] == ["test-ext"] + # Verify it was actually saved to disk + raw_config = yaml.safe_load(config_path.read_text()) + assert raw_config["installed"] == ["test-ext"] + From 6da7a6c81105a8fd12464c41e2e255cbe07ae010 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 11 May 2026 21:49:24 +0000 Subject: [PATCH 10/30] Hardening extension registration: support mapping entries, improve persistence, and fix update rollback --- src/specify_cli/__init__.py | 11 ++++- src/specify_cli/extensions.py | 66 +++++++++++++++++----------- tests/test_extension_registration.py | 48 ++++++++++++++++++++ 3 files changed, 98 insertions(+), 27 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 325692900e..8508f4bb18 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -4851,8 +4851,8 @@ def extension_update( backup_config_dir = backup_base / "config" # Store backup state - backup_registry_entry = None backup_hooks = None # None means no hooks key in config; {} means hooks key existed + backup_installed = None # Original installed list from extensions.yml backed_up_command_files = {} try: @@ -4903,10 +4903,11 @@ def extension_update( shutil.copy2(prompt_file, backup_prompt_path) backed_up_command_files[str(prompt_file)] = str(backup_prompt_path) - # 4. Backup hooks from extensions.yml + # 4. Backup hooks and installed list from extensions.yml # Use backup_hooks=None to indicate config had no "hooks" key (don't create on restore) # Use backup_hooks={} to indicate config had "hooks" key with no hooks for this extension config = hook_executor.get_project_config() + backup_installed = config.get("installed") if "hooks" in config: backup_hooks = {} # Config has hooks key - preserve this fact for hook_name, hook_list in config["hooks"].items(): @@ -5096,6 +5097,12 @@ def extension_update( if modified: hook_executor.save_project_config(config) + # Restore installed list in extensions.yml + if backup_installed is not None: + config = hook_executor.get_project_config() + config["installed"] = backup_installed + hook_executor.save_project_config(config) + # Restore registry entry (use restore() since entry was removed) if backup_registry_entry: manager.registry.restore(extension_id, backup_registry_entry) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 1d0ad96b8d..6ead1ebbf9 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2513,24 +2513,34 @@ def register_extension(self, extension_id: str): if not isinstance(config, dict): config = {} - # Load and sanitize existing installed list - installed = config.get("installed") - if not isinstance(installed, list): - installed = [] + # Load existing installed list, defaulting to [] if missing or corrupted + raw_installed = config.get("installed") + installed = raw_installed if isinstance(raw_installed, list) else [] - # Capture original state to check for changes (Feedback from review) - original_installed = list(installed) - - # Sanitize: keep only strings - sanitized = [x for x in installed if isinstance(x, str)] + # Sanitize: keep strings and mappings with an 'id' (Feedback from review) + sanitized = [ + x for x in installed + if isinstance(x, str) or (isinstance(x, dict) and isinstance(x.get("id"), str)) + ] - if extension_id not in sanitized: + # Check if already present (as string or in a mapping) + already_present = False + for x in sanitized: + if x == extension_id or (isinstance(x, dict) and x.get("id") == extension_id): + already_present = True + break + + if not already_present: sanitized.append(extension_id) - # Maintain alphabetical order for readability and diff stability - sanitized.sort() + # Maintain alphabetical order by ID for readability and diff stability + def _get_sort_id(x): + return x if isinstance(x, str) else x.get("id", "") + + sanitized.sort(key=_get_sort_id) - if sanitized != original_installed: + # Always persist if sanitized state differs from raw config (ensures normalization) + if sanitized != raw_installed: config["installed"] = sanitized self.save_project_config(config) @@ -2545,23 +2555,29 @@ def unregister_extension(self, extension_id: str): if not isinstance(config, dict): return - installed = config.get("installed") - if not isinstance(installed, list): - return - - # Capture original state to check for changes (Feedback from review) - original_installed = list(installed) + # Load existing installed list, defaulting to [] if missing or corrupted + raw_installed = config.get("installed") + installed = raw_installed if isinstance(raw_installed, list) else [] - # Sanitize and remove - sanitized = [x for x in installed if isinstance(x, str)] + # Sanitize and remove (Feedback from review: support mappings) + sanitized = [ + x for x in installed + if isinstance(x, str) or (isinstance(x, dict) and isinstance(x.get("id"), str)) + ] - if extension_id in sanitized: - sanitized.remove(extension_id) + sanitized = [ + x for x in sanitized + if not (x == extension_id or (isinstance(x, dict) and x.get("id") == extension_id)) + ] # Maintain alphabetical order for consistency - sanitized.sort() + def _get_sort_id(x): + return x if isinstance(x, str) else x.get("id", "") + + sanitized.sort(key=_get_sort_id) - if sanitized != original_installed: + # Always persist if sanitized state differs from raw config (ensures normalization) + if sanitized != raw_installed: config["installed"] = sanitized self.save_project_config(config) diff --git a/tests/test_extension_registration.py b/tests/test_extension_registration.py index fc5a1404f7..dca62f435c 100644 --- a/tests/test_extension_registration.py +++ b/tests/test_extension_registration.py @@ -375,3 +375,51 @@ def test_register_extension_already_present_in_corrupted_list(self, project_dir) raw_config = yaml.safe_load(config_path.read_text()) assert raw_config["installed"] == ["test-ext"] + def test_register_extension_with_dict_entry(self, project_dir): + """Review Feedback: register_extension should support and preserve dict entries.""" + executor = HookExecutor(project_dir) + config_path = project_dir / ".specify" / "extensions.yml" + + # Setup config with a pinned extension (dict) + pinned_ext = {"id": "pinned-ext", "version": "1.0.0"} + config_path.write_text(yaml.dump({ + "installed": [pinned_ext, "string-ext"] + })) + + # Register a new extension + executor.register_extension("new-ext") + + config = executor.get_project_config() + # Should contain all three, sorted by id: new-ext, pinned-ext, string-ext + assert config["installed"] == ["new-ext", pinned_ext, "string-ext"] + + def test_unregister_extension_with_dict_entry(self, project_dir): + """Review Feedback: unregister_extension should support removing matching dict entries.""" + executor = HookExecutor(project_dir) + config_path = project_dir / ".specify" / "extensions.yml" + + pinned_ext = {"id": "to-remove", "version": "1.0.0"} + config_path.write_text(yaml.dump({ + "installed": [pinned_ext, "other-ext"] + })) + + # Unregister by ID + executor.unregister_extension("to-remove") + + config = executor.get_project_config() + assert config["installed"] == ["other-ext"] + + def test_unregister_extension_corrupted_installed(self, project_dir): + """Hardening: unregister_extension should handle non-list installed key.""" + executor = HookExecutor(project_dir) + config_path = project_dir / ".specify" / "extensions.yml" + + config_path.write_text(yaml.dump({ + "installed": "not-a-list" + })) + + # Should not crash and should normalize to [] + executor.unregister_extension("any-ext") + + config = executor.get_project_config() + assert config["installed"] == [] From 0c83a00502d7f19e7afef70f06486283abfa0478 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 11 May 2026 22:31:22 +0000 Subject: [PATCH 11/30] fix(cli): harden extension update and unregistration workflows --- src/specify_cli/__init__.py | 31 ++++++++++++++++++++++++---- src/specify_cli/extensions.py | 5 ++++- tests/test_extension_registration.py | 31 ++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 8508f4bb18..5a7b823036 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -4907,11 +4907,21 @@ def extension_update( # Use backup_hooks=None to indicate config had no "hooks" key (don't create on restore) # Use backup_hooks={} to indicate config had "hooks" key with no hooks for this extension config = hook_executor.get_project_config() - backup_installed = config.get("installed") + + # Defensive: ensure config is a dict (Review Feedback) + if not isinstance(config, dict): + config = {} + + # Sentinel for missing key (Review Feedback) + MISSING = object() + backup_installed = config.get("installed", MISSING) + if "hooks" in config: backup_hooks = {} # Config has hooks key - preserve this fact for hook_name, hook_list in config["hooks"].items(): - ext_hooks = [h for h in hook_list if h.get("extension") == extension_id] + if not isinstance(hook_list, list): + continue + ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id] if ext_hooks: backup_hooks[hook_name] = ext_hooks @@ -5098,10 +5108,23 @@ def extension_update( hook_executor.save_project_config(config) # Restore installed list in extensions.yml - if backup_installed is not None: + if backup_installed is not MISSING: config = hook_executor.get_project_config() - config["installed"] = backup_installed + if not isinstance(config, dict): + config = {} + + if backup_installed is None: + # Original was present but null + config["installed"] = None + else: + config["installed"] = backup_installed hook_executor.save_project_config(config) + else: + # Original was missing entirely + config = hook_executor.get_project_config() + if isinstance(config, dict) and "installed" in config: + del config["installed"] + hook_executor.save_project_config(config) # Restore registry entry (use restore() since entry was removed) if backup_registry_entry: diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 6ead1ebbf9..53bbcf1e2f 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2553,7 +2553,7 @@ def unregister_extension(self, extension_id: str): config = self.get_project_config() if not isinstance(config, dict): - return + config = {} # Load existing installed list, defaulting to [] if missing or corrupted raw_installed = config.get("installed") @@ -2656,6 +2656,9 @@ def unregister_hooks(self, extension_id: str): config = self.get_project_config() if not isinstance(config, dict): + config = {} + # We don't save yet, as there are no hooks to unregister, + # but unregister_extension above might have already saved a normalized config. return if "hooks" not in config or not isinstance(config["hooks"], dict): diff --git a/tests/test_extension_registration.py b/tests/test_extension_registration.py index dca62f435c..bc7396d768 100644 --- a/tests/test_extension_registration.py +++ b/tests/test_extension_registration.py @@ -423,3 +423,34 @@ def test_unregister_extension_corrupted_installed(self, project_dir): config = executor.get_project_config() assert config["installed"] == [] + + def test_unregister_extension_scalar_root(self, project_dir): + """Hardening: unregister_extension should handle scalar root config.""" + executor = HookExecutor(project_dir) + config_path = project_dir / ".specify" / "extensions.yml" + + config_path.write_text(yaml.dump(123)) + + # Should not crash and should normalize to {} + executor.unregister_extension("any-ext") + + config = executor.get_project_config() + assert isinstance(config, dict) + assert config["installed"] == [] + + def test_unregister_hooks_scalar_hook_values(self, project_dir): + """Regression: unregister_hooks() must handle scalar hook event values.""" + executor = HookExecutor(project_dir) + config_path = project_dir / ".specify" / "extensions.yml" + + config_path.write_text(yaml.dump({ + "installed": ["some-ext"], + "hooks": {"after_tasks": 123} + })) + + # Should not raise TypeError when iterating + executor.unregister_hooks("some-ext") + + config = executor.get_project_config() + assert "some-ext" not in config["installed"] + assert "after_tasks" not in config["hooks"] From 2279905d2c8fa8dff7c3d70dd2ae615298dc3538 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 11 May 2026 23:45:58 +0000 Subject: [PATCH 12/30] fix(cli): move update sentinels outside try block to prevent NameError on rollback --- src/specify_cli/__init__.py | 41 ++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5a7b823036..e859681e7a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -4839,6 +4839,10 @@ def extension_update( registrar = CommandRegistrar() hook_executor = HookExecutor(project_root) + # Sentinels for atomic backup/restore (Review Feedback) + UNSET = object() + MISSING = object() + for update in updates_available: extension_id = update["id"] ext_name = update["name"] # Use display name for user-facing messages @@ -4852,7 +4856,7 @@ def extension_update( # Store backup state backup_hooks = None # None means no hooks key in config; {} means hooks key existed - backup_installed = None # Original installed list from extensions.yml + backup_installed = UNSET # Original installed list from extensions.yml backed_up_command_files = {} try: @@ -4912,8 +4916,6 @@ def extension_update( if not isinstance(config, dict): config = {} - # Sentinel for missing key (Review Feedback) - MISSING = object() backup_installed = config.get("installed", MISSING) if "hooks" in config: @@ -5108,23 +5110,24 @@ def extension_update( hook_executor.save_project_config(config) # Restore installed list in extensions.yml - if backup_installed is not MISSING: - config = hook_executor.get_project_config() - if not isinstance(config, dict): - config = {} - - if backup_installed is None: - # Original was present but null - config["installed"] = None - else: - config["installed"] = backup_installed - hook_executor.save_project_config(config) - else: - # Original was missing entirely - config = hook_executor.get_project_config() - if isinstance(config, dict) and "installed" in config: - del config["installed"] + if backup_installed is not UNSET: + if backup_installed is not MISSING: + config = hook_executor.get_project_config() + if not isinstance(config, dict): + config = {} + + if backup_installed is None: + # Original was present but null + config["installed"] = None + else: + config["installed"] = backup_installed hook_executor.save_project_config(config) + else: + # Original was missing entirely + config = hook_executor.get_project_config() + if isinstance(config, dict) and "installed" in config: + del config["installed"] + hook_executor.save_project_config(config) # Restore registry entry (use restore() since entry was removed) if backup_registry_entry: From 1c4d01f4a7b77c0eb167d72835e79b97961602ff Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 00:50:40 +0000 Subject: [PATCH 13/30] fix(cli): sanitize hook event lists in register_hooks to prevent crashes --- src/specify_cli/extensions.py | 12 ++++++++ tests/test_extension_registration.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 53bbcf1e2f..33455d36e4 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2605,6 +2605,18 @@ def register_hooks(self, manifest: ExtensionManifest): if "hooks" not in config or not isinstance(config["hooks"], dict): config["hooks"] = {} changed = True + else: + # Sanitize existing hook lists to prevent crashes in downstream code (Feedback) + for h_name in list(config["hooks"].keys()): + h_list = config["hooks"][h_name] + if not isinstance(h_list, list): + config["hooks"][h_name] = [] + changed = True + else: + sanitized_h_list = [h for h in h_list if isinstance(h, dict)] + if len(sanitized_h_list) != len(h_list): + config["hooks"][h_name] = sanitized_h_list + changed = True # Register each hook for hook_name, hook_config in manifest.hooks.items(): diff --git a/tests/test_extension_registration.py b/tests/test_extension_registration.py index bc7396d768..6f34cf207f 100644 --- a/tests/test_extension_registration.py +++ b/tests/test_extension_registration.py @@ -423,6 +423,48 @@ def test_unregister_extension_corrupted_installed(self, project_dir): config = executor.get_project_config() assert config["installed"] == [] + def test_register_hooks_mixed_type_hook_list(self, project_dir, tmp_path): + """Regression: register_hooks() must sanitize hook event lists by dropping non-dicts.""" + executor = HookExecutor(project_dir) + + config_path = project_dir / ".specify" / "extensions.yml" + config_path.write_text(yaml.dump({ + "installed": ["some-ext"], + "hooks": {"after_tasks": [1, "corrupted", {"extension": "other", "command": "cmd"}]} + })) + + manifest_path = tmp_path / "extension.yml" + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "new-ext", + "name": "New Ext", + "version": "1.0.0", + "description": "Test", + "author": "Test author" + }, + "requires": { + "speckit_version": ">=0.1.0", + "commands": [] + }, + "provides": {"commands": []}, + "hooks": { + "after_tasks": {"command": "new-cmd"} + } + } + manifest_path.write_text(yaml.dump(manifest_data)) + manifest = ExtensionManifest(manifest_path) + + executor.register_hooks(manifest) + + config = executor.get_project_config() + hooks = config["hooks"]["after_tasks"] + + # Should have 2 valid dict hooks, and 0 non-dict items + assert len(hooks) == 2 + assert all(isinstance(h, dict) for h in hooks) + assert any(h.get("extension") == "other" for h in hooks) + assert any(h.get("extension") == "new-ext" for h in hooks) def test_unregister_extension_scalar_root(self, project_dir): """Hardening: unregister_extension should handle scalar root config.""" From 353f2b60d1095dc461bafe1f24d8cbb3be332f89 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 02:19:57 +0000 Subject: [PATCH 14/30] fix(cli): deduplicate hook entries and harden rollback hooks-restore guards --- src/specify_cli/__init__.py | 73 ++++++++++++++++++++--------------- src/specify_cli/extensions.py | 26 ++++++------- 2 files changed, 53 insertions(+), 46 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e859681e7a..69a224f381 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -4855,8 +4855,8 @@ def extension_update( backup_config_dir = backup_base / "config" # Store backup state - backup_hooks = None # None means no hooks key in config; {} means hooks key existed backup_installed = UNSET # Original installed list from extensions.yml + backup_hooks = None # None means no hooks key in config; {} means hooks key existed backed_up_command_files = {} try: @@ -4882,7 +4882,7 @@ def extension_update( # 3. Backup command files for all agents from .agents import CommandRegistrar as _AgentReg - registered_commands = backup_registry_entry.get("registered_commands", {}) + registered_commands = backup_registry_entry.get("registered_commands", {}) if isinstance(backup_registry_entry, dict) else {} for agent_name, cmd_names in registered_commands.items(): if agent_name not in registrar.AGENT_CONFIGS: continue @@ -4913,19 +4913,20 @@ def extension_update( config = hook_executor.get_project_config() # Defensive: ensure config is a dict (Review Feedback) - if not isinstance(config, dict): - config = {} - - backup_installed = config.get("installed", MISSING) - - if "hooks" in config: - backup_hooks = {} # Config has hooks key - preserve this fact - for hook_name, hook_list in config["hooks"].items(): - if not isinstance(hook_list, list): - continue - ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id] - if ext_hooks: - backup_hooks[hook_name] = ext_hooks + if isinstance(config, dict): + backup_installed = config.get("installed", MISSING) + + hooks = config.get("hooks") + if isinstance(hooks, dict): + backup_hooks = {} # Config has hooks key - preserve this fact + for hook_name, hook_list in hooks.items(): + if not isinstance(hook_list, list): + continue + ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id] + if ext_hooks: + backup_hooks[hook_name] = ext_hooks + else: + backup_installed = MISSING # 5. Download new version zip_path = catalog.download_extension(extension_id) @@ -5080,6 +5081,9 @@ def extension_update( # - backup_hooks=None means original config had no "hooks" key # - backup_hooks={} or {...} means config had hooks key config = hook_executor.get_project_config() + # Defensive: coerce non-dict config (Feedback from review) + if not isinstance(config, dict): + config = {} if "hooks" in config: modified = False @@ -5088,23 +5092,28 @@ def extension_update( del config["hooks"] modified = True else: - # Remove any hooks for this extension added by failed install - for hook_name, hooks_list in config["hooks"].items(): - original_len = len(hooks_list) - config["hooks"][hook_name] = [ - h for h in hooks_list - if h.get("extension") != extension_id - ] - if len(config["hooks"][hook_name]) != original_len: - modified = True - - # Add back the backed up hooks if any - if backup_hooks: - for hook_name, hooks in backup_hooks.items(): - if hook_name not in config["hooks"]: - config["hooks"][hook_name] = [] - config["hooks"][hook_name].extend(hooks) - modified = True + # Guard: skip if hooks is not a dict (corrupted config) + if isinstance(config["hooks"], dict): + # Remove any hooks for this extension added by failed install + for hook_name in list(config["hooks"].keys()): + hooks_list = config["hooks"][hook_name] + if not isinstance(hooks_list, list): + continue + original_len = len(hooks_list) + config["hooks"][hook_name] = [ + h for h in hooks_list + if isinstance(h, dict) and h.get("extension") != extension_id + ] + if len(config["hooks"][hook_name]) != original_len: + modified = True + + # Add back the backed up hooks if any + if backup_hooks: + for hook_name, hooks in backup_hooks.items(): + if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list): + config["hooks"][hook_name] = [] + config["hooks"][hook_name].extend(hooks) + modified = True if modified: hook_executor.save_project_config(config) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 33455d36e4..a0434ea9f8 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2637,21 +2637,19 @@ def register_hooks(self, manifest: ExtensionManifest): "condition": hook_config.get("condition"), } - # Check if already registered - existing_idx = -1 - for i, h in enumerate(config["hooks"][hook_name]): - if isinstance(h, dict) and h.get("extension") == manifest.id: - existing_idx = i - break - - if existing_idx == -1: - config["hooks"][hook_name].append(hook_entry) + # Deduplicate: remove all existing entries for this extension on this + # hook event, then append the single canonical entry. This prevents + # multiple hooks firing when hand-edited or older versions leave + # duplicate entries behind. (Feedback from review) + original_list = config["hooks"][hook_name] + deduped = [ + h for h in original_list + if not (isinstance(h, dict) and h.get("extension") == manifest.id) + ] + deduped.append(hook_entry) + if deduped != original_list: + config["hooks"][hook_name] = deduped changed = True - else: - # Update existing if changed - if config["hooks"][hook_name][existing_idx] != hook_entry: - config["hooks"][hook_name][existing_idx] = hook_entry - changed = True if changed: self.save_project_config(config) From f71a37af86f9e06aa94d8b51949150046caf7425 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 04:26:49 +0000 Subject: [PATCH 15/30] test(cli): add regression tests for extension update and rollback hardening --- tests/test_extension_update_hardening.py | 95 ++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/test_extension_update_hardening.py diff --git a/tests/test_extension_update_hardening.py b/tests/test_extension_update_hardening.py new file mode 100644 index 0000000000..c724bcc86e --- /dev/null +++ b/tests/test_extension_update_hardening.py @@ -0,0 +1,95 @@ +import pytest +import yaml +from pathlib import Path +from typer.testing import CliRunner +from specify_cli import app +from specify_cli.extensions import HookExecutor + +runner = CliRunner() + +@pytest.fixture +def project_dir(tmp_path): + """Create a mock spec-kit project directory.""" + proj_dir = tmp_path / "project" + proj_dir.mkdir() + (proj_dir / ".specify").mkdir() + # Create required files for a project + (proj_dir / ".specify" / "config.toml").write_text("ai = 'claude'") + return proj_dir + +def test_extension_update_corrupted_config_root(project_dir, monkeypatch): + """Regression: extension update must handle corrupted extensions.yml (root is scalar).""" + # Corrupt extensions.yml + config_path = project_dir / ".specify" / "extensions.yml" + config_path.write_text(yaml.dump(123)) + + # Mock ExtensionManager to return an installed extension for resolution + from specify_cli.extensions import ExtensionManager, ExtensionCatalog, ExtensionRegistry + + def mock_list_installed(self): + return [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}] + + def mock_get(self, ext_id): + return {"version": "1.0.0", "enabled": True} + + def mock_get_extension_info(self, ext_id): + return {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"} + + monkeypatch.setattr(ExtensionManager, "list_installed", mock_list_installed) + monkeypatch.setattr(ExtensionRegistry, "get", mock_get) + monkeypatch.setattr(ExtensionCatalog, "get_extension_info", mock_get_extension_info) + + # Mock confirmation to true + monkeypatch.setattr("typer.confirm", lambda _: True) + + # Run update + result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir}) + + # It might fail because of the mock zip, but it should NOT be an AttributeError from config.get() + assert "AttributeError" not in result.output + +def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch): + """Regression: extension update must handle non-dict 'hooks' in extensions.yml.""" + config_path = project_dir / ".specify" / "extensions.yml" + config_path.write_text(yaml.dump({ + "installed": ["test-ext"], + "hooks": ["not", "a", "dict"] + })) + + from specify_cli.extensions import ExtensionManager, ExtensionCatalog, ExtensionRegistry + + monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}]) + monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) + monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"}) + monkeypatch.setattr("typer.confirm", lambda _: True) + + result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir}) + + assert "AttributeError" not in result.output + +def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch): + """Regression: extension update rollback must handle corrupted extensions.yml.""" + config_path = project_dir / ".specify" / "extensions.yml" + config_path.write_text(yaml.dump({"installed": ["test-ext"]})) + + from specify_cli.extensions import ExtensionManager, ExtensionCatalog, ExtensionRegistry + + # Mock update process to fail after backup + monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}]) + monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) + + # Force failure in download_extension to trigger rollback + def mock_download_fail(*args, **kwargs): + # Corrupt the config BEFORE rollback is triggered + config_path.write_text(yaml.dump("CORRUPTED")) + raise Exception("Download failed") + + monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"}) + monkeypatch.setattr(ExtensionCatalog, "download_extension", mock_download_fail) + monkeypatch.setattr("typer.confirm", lambda _: True) + + result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir}) + + # Should handle Exception and NOT crash with AttributeError during rollback + assert "Download failed" in result.output + assert "AttributeError" not in result.output From cdda39ad33dd98f390db1c181447cf9020acf2a6 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 04:35:04 +0000 Subject: [PATCH 16/30] fix(cli): deduplicate installed list by id in register_extension --- src/specify_cli/extensions.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index a0434ea9f8..d48bdf2487 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2517,27 +2517,29 @@ def register_extension(self, extension_id: str): raw_installed = config.get("installed") installed = raw_installed if isinstance(raw_installed, list) else [] - # Sanitize: keep strings and mappings with an 'id' (Feedback from review) - sanitized = [ - x for x in installed + # Sanitize: keep strings and mappings with a valid 'id' + valid = [ + x for x in installed if isinstance(x, str) or (isinstance(x, dict) and isinstance(x.get("id"), str)) ] - - # Check if already present (as string or in a mapping) - already_present = False - for x in sanitized: - if x == extension_id or (isinstance(x, dict) and x.get("id") == extension_id): - already_present = True - break - - if not already_present: - sanitized.append(extension_id) - + + # Deduplicate by id: prefer dict (richer metadata) over plain string for + # the same id; keep only one canonical entry per id. (Feedback) + seen: dict = {} # id -> entry (dict preferred over str) + for x in valid: + eid = x if isinstance(x, str) else x.get("id", "") + if eid not in seen or isinstance(x, dict): + seen[eid] = x + + # Ensure the target extension id is registered (as a plain string) + if extension_id not in seen: + seen[extension_id] = extension_id + # Maintain alphabetical order by ID for readability and diff stability def _get_sort_id(x): return x if isinstance(x, str) else x.get("id", "") - - sanitized.sort(key=_get_sort_id) + + sanitized = sorted(seen.values(), key=_get_sort_id) # Always persist if sanitized state differs from raw config (ensures normalization) if sanitized != raw_installed: From d8a091eb353a1eac976abe090a60e7df603e9844 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 06:37:18 +0000 Subject: [PATCH 17/30] fix(cli): consolidate and harden extension update rollback logic --- src/specify_cli/__init__.py | 120 +++++++++++------------ tests/test_extension_update_hardening.py | 35 ++++--- 2 files changed, 79 insertions(+), 76 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 69a224f381..85c9b65480 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -4916,15 +4916,16 @@ def extension_update( if isinstance(config, dict): backup_installed = config.get("installed", MISSING) - hooks = config.get("hooks") - if isinstance(hooks, dict): - backup_hooks = {} # Config has hooks key - preserve this fact - for hook_name, hook_list in hooks.items(): - if not isinstance(hook_list, list): - continue - ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id] - if ext_hooks: - backup_hooks[hook_name] = ext_hooks + if "hooks" in config: + backup_hooks = {} # Preserve key presence even if value is non-dict + hooks = config.get("hooks") + if isinstance(hooks, dict): + for hook_name, hook_list in hooks.items(): + if not isinstance(hook_list, list): + continue + ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id] + if ext_hooks: + backup_hooks[hook_name] = ext_hooks else: backup_installed = MISSING @@ -5077,66 +5078,61 @@ def extension_update( original_file.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(backup_file, original_file) - # Restore hooks in extensions.yml - # - backup_hooks=None means original config had no "hooks" key - # - backup_hooks={} or {...} means config had hooks key + # Restore metadata in extensions.yml (hooks and installed list) config = hook_executor.get_project_config() - # Defensive: coerce non-dict config (Feedback from review) if not isinstance(config, dict): config = {} - if "hooks" in config: - modified = False - - if backup_hooks is None: - # Original config had no "hooks" key; remove it entirely + + modified = False + + # 1. Restore hooks in extensions.yml + # - backup_hooks=None means original config had no "hooks" key + # - backup_hooks={} or {...} means config had hooks key + if backup_hooks is None: + if "hooks" in config: del config["hooks"] modified = True - else: - # Guard: skip if hooks is not a dict (corrupted config) - if isinstance(config["hooks"], dict): - # Remove any hooks for this extension added by failed install - for hook_name in list(config["hooks"].keys()): - hooks_list = config["hooks"][hook_name] - if not isinstance(hooks_list, list): - continue - original_len = len(hooks_list) - config["hooks"][hook_name] = [ - h for h in hooks_list - if isinstance(h, dict) and h.get("extension") != extension_id - ] - if len(config["hooks"][hook_name]) != original_len: - modified = True - - # Add back the backed up hooks if any - if backup_hooks: - for hook_name, hooks in backup_hooks.items(): - if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list): - config["hooks"][hook_name] = [] - config["hooks"][hook_name].extend(hooks) - modified = True - - if modified: - hook_executor.save_project_config(config) - - # Restore installed list in extensions.yml - if backup_installed is not UNSET: - if backup_installed is not MISSING: - config = hook_executor.get_project_config() - if not isinstance(config, dict): - config = {} + else: + if "hooks" not in config or not isinstance(config["hooks"], dict): + config["hooks"] = {} + modified = True + + # Remove any hooks for this extension added by failed install + for hook_name in list(config["hooks"].keys()): + hooks_list = config["hooks"][hook_name] + if not isinstance(hooks_list, list): + config["hooks"][hook_name] = [] + continue - if backup_installed is None: - # Original was present but null - config["installed"] = None - else: - config["installed"] = backup_installed - hook_executor.save_project_config(config) - else: - # Original was missing entirely - config = hook_executor.get_project_config() - if isinstance(config, dict) and "installed" in config: + original_len = len(hooks_list) + config["hooks"][hook_name] = [ + h for h in hooks_list + if isinstance(h, dict) and h.get("extension") != extension_id + ] + if len(config["hooks"][hook_name]) != original_len: + modified = True + + # Add back the backed up hooks if any + if backup_hooks: + for hook_name, hooks in backup_hooks.items(): + if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list): + config["hooks"][hook_name] = [] + config["hooks"][hook_name].extend(hooks) + modified = True + + # 2. Restore installed list in extensions.yml + if backup_installed is not UNSET: + if backup_installed is MISSING: + if "installed" in config: del config["installed"] - hook_executor.save_project_config(config) + modified = True + else: + if config.get("installed") != backup_installed: + config["installed"] = backup_installed + modified = True + + if modified: + hook_executor.save_project_config(config) # Restore registry entry (use restore() since entry was removed) if backup_registry_entry: diff --git a/tests/test_extension_update_hardening.py b/tests/test_extension_update_hardening.py index c724bcc86e..5ba69030d1 100644 --- a/tests/test_extension_update_hardening.py +++ b/tests/test_extension_update_hardening.py @@ -1,9 +1,7 @@ import pytest import yaml -from pathlib import Path from typer.testing import CliRunner from specify_cli import app -from specify_cli.extensions import HookExecutor runner = CliRunner() @@ -19,6 +17,9 @@ def project_dir(tmp_path): def test_extension_update_corrupted_config_root(project_dir, monkeypatch): """Regression: extension update must handle corrupted extensions.yml (root is scalar).""" + # chdir into project_dir so _require_specify_project() succeeds + monkeypatch.chdir(project_dir) + # Corrupt extensions.yml config_path = project_dir / ".specify" / "extensions.yml" config_path.write_text(yaml.dump(123)) @@ -26,18 +27,12 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch): # Mock ExtensionManager to return an installed extension for resolution from specify_cli.extensions import ExtensionManager, ExtensionCatalog, ExtensionRegistry - def mock_list_installed(self): - return [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}] + monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}]) + monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) + monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"}) - def mock_get(self, ext_id): - return {"version": "1.0.0", "enabled": True} - - def mock_get_extension_info(self, ext_id): - return {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"} - - monkeypatch.setattr(ExtensionManager, "list_installed", mock_list_installed) - monkeypatch.setattr(ExtensionRegistry, "get", mock_get) - monkeypatch.setattr(ExtensionCatalog, "get_extension_info", mock_get_extension_info) + # Mock download_extension to avoid network calls + monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: "/tmp/mock.zip") # Mock confirmation to true monkeypatch.setattr("typer.confirm", lambda _: True) @@ -50,6 +45,8 @@ def mock_get_extension_info(self, ext_id): def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch): """Regression: extension update must handle non-dict 'hooks' in extensions.yml.""" + monkeypatch.chdir(project_dir) + config_path = project_dir / ".specify" / "extensions.yml" config_path.write_text(yaml.dump({ "installed": ["test-ext"], @@ -61,6 +58,7 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch): monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}]) monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"}) + monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: "/tmp/mock.zip") monkeypatch.setattr("typer.confirm", lambda _: True) result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir}) @@ -69,8 +67,11 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch): def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch): """Regression: extension update rollback must handle corrupted extensions.yml.""" + monkeypatch.chdir(project_dir) + config_path = project_dir / ".specify" / "extensions.yml" - config_path.write_text(yaml.dump({"installed": ["test-ext"]})) + # hooks: null should be preserved even after a failed update attempt + config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": None})) from specify_cli.extensions import ExtensionManager, ExtensionCatalog, ExtensionRegistry @@ -93,3 +94,9 @@ def mock_download_fail(*args, **kwargs): # Should handle Exception and NOT crash with AttributeError during rollback assert "Download failed" in result.output assert "AttributeError" not in result.output + + # Verify hooks key was preserved (normalized to {} if it was null/corrupted) + restored_config = yaml.safe_load(config_path.read_text()) + assert isinstance(restored_config, dict) + assert "hooks" in restored_config + assert restored_config["hooks"] == {} From b1074a28d2355b458a5c6dc02d7bc88088e1c187 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 06:43:13 +0000 Subject: [PATCH 18/30] fix(cli): initialize backup_registry_entry before try block to prevent UnboundLocalError on rollback --- src/specify_cli/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 85c9b65480..9a44ef9489 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -4855,6 +4855,7 @@ def extension_update( backup_config_dir = backup_base / "config" # Store backup state + backup_registry_entry = None # None means registry entry not yet captured backup_installed = UNSET # Original installed list from extensions.yml backup_hooks = None # None means no hooks key in config; {} means hooks key existed backed_up_command_files = {} From bad7c92e6cbeb48c79db749fb995376448160612 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 06:52:25 +0000 Subject: [PATCH 19/30] fix(tests): return Path from download_extension mock and add Path import --- tests/test_extension_update_hardening.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_extension_update_hardening.py b/tests/test_extension_update_hardening.py index 5ba69030d1..557949509d 100644 --- a/tests/test_extension_update_hardening.py +++ b/tests/test_extension_update_hardening.py @@ -1,5 +1,6 @@ import pytest import yaml +from pathlib import Path from typer.testing import CliRunner from specify_cli import app @@ -31,8 +32,9 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch): monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"}) - # Mock download_extension to avoid network calls - monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: "/tmp/mock.zip") + # Mock download_extension to avoid network calls; return Path so zip_path.exists() + # / zip_path.unlink() in the finally block work without AttributeError + monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: Path("/tmp/mock.zip")) # Mock confirmation to true monkeypatch.setattr("typer.confirm", lambda _: True) @@ -58,7 +60,8 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch): monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}]) monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"}) - monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: "/tmp/mock.zip") + # Return Path so zip_path.exists() / zip_path.unlink() in the finally block work correctly + monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: Path("/tmp/mock.zip")) monkeypatch.setattr("typer.confirm", lambda _: True) result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir}) From 1f0e3d27167b374a5240235784f74e07e319c8fd Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 06:57:26 +0000 Subject: [PATCH 20/30] fix(cli): normalize get_project_config() return to dict; deduplicate in unregister_extension() --- src/specify_cli/extensions.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index d48bdf2487..3e5981c178 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2481,7 +2481,9 @@ def get_project_config(self) -> Dict[str, Any]: } try: - return yaml.safe_load(self.config_file.read_text(encoding="utf-8")) or {} + result = yaml.safe_load(self.config_file.read_text(encoding="utf-8")) + # Coerce non-dict results (scalars, lists) to {} so all callers are safe (Feedback) + return result if isinstance(result, dict) else {} except (yaml.YAMLError, OSError, UnicodeError): return { "installed": [], @@ -2561,22 +2563,27 @@ def unregister_extension(self, extension_id: str): raw_installed = config.get("installed") installed = raw_installed if isinstance(raw_installed, list) else [] - # Sanitize and remove (Feedback from review: support mappings) - sanitized = [ - x for x in installed + # Sanitize, deduplicate by id, and remove the target extension. + # Mirrors register_extension(): one canonical entry per id, dict preferred over str. + valid = [ + x for x in installed if isinstance(x, str) or (isinstance(x, dict) and isinstance(x.get("id"), str)) ] - - sanitized = [ - x for x in sanitized - if not (x == extension_id or (isinstance(x, dict) and x.get("id") == extension_id)) - ] - + + seen: dict = {} # id -> entry (dict preferred over str) + for x in valid: + eid = x if isinstance(x, str) else x.get("id", "") + if eid not in seen or isinstance(x, dict): + seen[eid] = x + + # Remove the target extension + seen.pop(extension_id, None) + # Maintain alphabetical order for consistency def _get_sort_id(x): return x if isinstance(x, str) else x.get("id", "") - - sanitized.sort(key=_get_sort_id) + + sanitized = sorted(seen.values(), key=_get_sort_id) # Always persist if sanitized state differs from raw config (ensures normalization) if sanitized != raw_installed: From 614b9f5408a566f16309947bd43b09631232adfb Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 08:06:34 +0000 Subject: [PATCH 21/30] fix(cli): normalize hooks/installed/settings in get_project_config(); use tmp_path-scoped zip in tests --- src/specify_cli/extensions.py | 14 ++++++++++++-- tests/test_extension_update_hardening.py | 12 +++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 3e5981c178..bb89341340 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2482,8 +2482,18 @@ def get_project_config(self) -> Dict[str, Any]: try: result = yaml.safe_load(self.config_file.read_text(encoding="utf-8")) - # Coerce non-dict results (scalars, lists) to {} so all callers are safe (Feedback) - return result if isinstance(result, dict) else {} + # Coerce non-dict root to {} so all callers are safe (Feedback) + if not isinstance(result, dict): + return {} + # Normalize nested fields so read-only callers like get_hooks_for_event() + # never see non-dict hooks or non-list installed (Feedback) + if not isinstance(result.get("hooks"), dict): + result["hooks"] = {} + if not isinstance(result.get("installed"), list): + result["installed"] = [] + if not isinstance(result.get("settings"), dict): + result["settings"] = {"auto_execute_hooks": True} + return result except (yaml.YAMLError, OSError, UnicodeError): return { "installed": [], diff --git a/tests/test_extension_update_hardening.py b/tests/test_extension_update_hardening.py index 557949509d..719139bdbc 100644 --- a/tests/test_extension_update_hardening.py +++ b/tests/test_extension_update_hardening.py @@ -32,9 +32,10 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch): monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"}) - # Mock download_extension to avoid network calls; return Path so zip_path.exists() - # / zip_path.unlink() in the finally block work without AttributeError - monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: Path("/tmp/mock.zip")) + # Mock download_extension to avoid network calls; use tmp_path so the test is hermetic + # and returns a Path so zip_path.exists() / zip_path.unlink() work without AttributeError + mock_zip = project_dir / "mock.zip" + monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip) # Mock confirmation to true monkeypatch.setattr("typer.confirm", lambda _: True) @@ -60,8 +61,9 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch): monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}]) monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"}) - # Return Path so zip_path.exists() / zip_path.unlink() in the finally block work correctly - monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: Path("/tmp/mock.zip")) + # Use tmp_path-scoped zip so the test is hermetic and returns a Path for zip_path.exists() + mock_zip = project_dir / "mock.zip" + monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip) monkeypatch.setattr("typer.confirm", lambda _: True) result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir}) From 07a6e02594a6942f2a44a44add8704d11c40cdc2 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 09:21:36 +0000 Subject: [PATCH 22/30] fix(cli): set modified=True on hook coercion in rollback; sanitize hook event values in get_project_config(); harden test assertions --- src/specify_cli/__init__.py | 1 + src/specify_cli/extensions.py | 5 +++++ tests/test_extension_update_hardening.py | 9 ++++----- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 9a44ef9489..1aff0ef96e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -5103,6 +5103,7 @@ def extension_update( hooks_list = config["hooks"][hook_name] if not isinstance(hooks_list, list): config["hooks"][hook_name] = [] + modified = True continue original_len = len(hooks_list) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index bb89341340..aff7a05dda 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2493,6 +2493,11 @@ def get_project_config(self) -> Dict[str, Any]: result["installed"] = [] if not isinstance(result.get("settings"), dict): result["settings"] = {"auto_execute_hooks": True} + # Sanitize hook event values: coerce non-list event values to [] + # so get_hooks_for_event() and other callers never see non-list values (Feedback) + for event_key in list(result["hooks"]): + if not isinstance(result["hooks"][event_key], list): + result["hooks"][event_key] = [] return result except (yaml.YAMLError, OSError, UnicodeError): return { diff --git a/tests/test_extension_update_hardening.py b/tests/test_extension_update_hardening.py index 719139bdbc..a17028f61c 100644 --- a/tests/test_extension_update_hardening.py +++ b/tests/test_extension_update_hardening.py @@ -1,6 +1,5 @@ import pytest import yaml -from pathlib import Path from typer.testing import CliRunner from specify_cli import app @@ -43,8 +42,8 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch): # Run update result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir}) - # It might fail because of the mock zip, but it should NOT be an AttributeError from config.get() - assert "AttributeError" not in result.output + # It might fail because of the mock zip, but must NOT raise AttributeError from config handling + assert not isinstance(result.exception, AttributeError) def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch): """Regression: extension update must handle non-dict 'hooks' in extensions.yml.""" @@ -68,7 +67,7 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch): result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir}) - assert "AttributeError" not in result.output + assert not isinstance(result.exception, AttributeError) def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch): """Regression: extension update rollback must handle corrupted extensions.yml.""" @@ -98,7 +97,7 @@ def mock_download_fail(*args, **kwargs): # Should handle Exception and NOT crash with AttributeError during rollback assert "Download failed" in result.output - assert "AttributeError" not in result.output + assert not isinstance(result.exception, AttributeError) # Verify hooks key was preserved (normalized to {} if it was null/corrupted) restored_config = yaml.safe_load(config_path.read_text()) From 160ecd5e95967cb9216f81bf50dd2cec9ed254b6 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 15:15:07 +0000 Subject: [PATCH 23/30] fix(cli): filter non-dict hook entries in get_project_config(); remove dead MISSING sentinel --- src/specify_cli/__init__.py | 98 ++++++++++++++--------------------- src/specify_cli/extensions.py | 9 ++-- 2 files changed, 45 insertions(+), 62 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1aff0ef96e..5a46524995 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -4839,9 +4839,8 @@ def extension_update( registrar = CommandRegistrar() hook_executor = HookExecutor(project_root) - # Sentinels for atomic backup/restore (Review Feedback) + # UNSET sentinel: backup not yet captured (exception before backup step) UNSET = object() - MISSING = object() for update in updates_available: extension_id = update["id"] @@ -4909,26 +4908,19 @@ def extension_update( backed_up_command_files[str(prompt_file)] = str(backup_prompt_path) # 4. Backup hooks and installed list from extensions.yml - # Use backup_hooks=None to indicate config had no "hooks" key (don't create on restore) - # Use backup_hooks={} to indicate config had "hooks" key with no hooks for this extension + # get_project_config() always normalizes installed->[] and hooks->{}, + # so no sentinel is needed to distinguish key-absent from key-empty. config = hook_executor.get_project_config() - - # Defensive: ensure config is a dict (Review Feedback) if isinstance(config, dict): - backup_installed = config.get("installed", MISSING) - - if "hooks" in config: - backup_hooks = {} # Preserve key presence even if value is non-dict - hooks = config.get("hooks") - if isinstance(hooks, dict): - for hook_name, hook_list in hooks.items(): - if not isinstance(hook_list, list): - continue - ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id] - if ext_hooks: - backup_hooks[hook_name] = ext_hooks - else: - backup_installed = MISSING + # Copy so mutations during update don't affect the backup + backup_installed = list(config.get("installed", [])) + backup_hooks = {} + for hook_name, hook_list in config.get("hooks", {}).items(): + if not isinstance(hook_list, list): + continue + ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id] + if ext_hooks: + backup_hooks[hook_name] = ext_hooks # 5. Download new version zip_path = catalog.download_extension(extension_id) @@ -5087,51 +5079,39 @@ def extension_update( modified = False # 1. Restore hooks in extensions.yml - # - backup_hooks=None means original config had no "hooks" key - # - backup_hooks={} or {...} means config had hooks key - if backup_hooks is None: - if "hooks" in config: - del config["hooks"] + if not isinstance(config.get("hooks"), dict): + config["hooks"] = {} + modified = True + + # Remove any hooks for this extension added by the failed install + for hook_name in list(config["hooks"].keys()): + hooks_list = config["hooks"][hook_name] + if not isinstance(hooks_list, list): + config["hooks"][hook_name] = [] modified = True - else: - if "hooks" not in config or not isinstance(config["hooks"], dict): - config["hooks"] = {} + continue + + original_len = len(hooks_list) + config["hooks"][hook_name] = [ + h for h in hooks_list + if isinstance(h, dict) and h.get("extension") != extension_id + ] + if len(config["hooks"][hook_name]) != original_len: modified = True - - # Remove any hooks for this extension added by failed install - for hook_name in list(config["hooks"].keys()): - hooks_list = config["hooks"][hook_name] - if not isinstance(hooks_list, list): + + # Add back the backed-up hooks + if backup_hooks: + for hook_name, hooks in backup_hooks.items(): + if not isinstance(config["hooks"].get(hook_name), list): config["hooks"][hook_name] = [] - modified = True - continue - - original_len = len(hooks_list) - config["hooks"][hook_name] = [ - h for h in hooks_list - if isinstance(h, dict) and h.get("extension") != extension_id - ] - if len(config["hooks"][hook_name]) != original_len: - modified = True - - # Add back the backed up hooks if any - if backup_hooks: - for hook_name, hooks in backup_hooks.items(): - if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list): - config["hooks"][hook_name] = [] - config["hooks"][hook_name].extend(hooks) - modified = True + config["hooks"][hook_name].extend(hooks) + modified = True # 2. Restore installed list in extensions.yml if backup_installed is not UNSET: - if backup_installed is MISSING: - if "installed" in config: - del config["installed"] - modified = True - else: - if config.get("installed") != backup_installed: - config["installed"] = backup_installed - modified = True + if config.get("installed") != backup_installed: + config["installed"] = backup_installed + modified = True if modified: hook_executor.save_project_config(config) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index aff7a05dda..aad34cd40e 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2493,11 +2493,14 @@ def get_project_config(self) -> Dict[str, Any]: result["installed"] = [] if not isinstance(result.get("settings"), dict): result["settings"] = {"auto_execute_hooks": True} - # Sanitize hook event values: coerce non-list event values to [] - # so get_hooks_for_event() and other callers never see non-list values (Feedback) + # Sanitize hook event values: coerce non-list values to [] and filter + # non-dict items so get_hooks_for_event() can safely call .get() (Feedback) for event_key in list(result["hooks"]): - if not isinstance(result["hooks"][event_key], list): + event_val = result["hooks"][event_key] + if not isinstance(event_val, list): result["hooks"][event_key] = [] + else: + result["hooks"][event_key] = [h for h in event_val if isinstance(h, dict)] return result except (yaml.YAMLError, OSError, UnicodeError): return { From 921f65d23a2561b5e21c8c5cd30402639de87b34 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 15:32:53 +0000 Subject: [PATCH 24/30] fix(cli): gate extensions.yml rollback on backup_hooks is not None; update stale comment --- src/specify_cli/__init__.py | 84 +++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5a46524995..4b71d265c7 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -4856,7 +4856,7 @@ def extension_update( # Store backup state backup_registry_entry = None # None means registry entry not yet captured backup_installed = UNSET # Original installed list from extensions.yml - backup_hooks = None # None means no hooks key in config; {} means hooks key existed + backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured backed_up_command_files = {} try: @@ -5071,50 +5071,54 @@ def extension_update( original_file.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(backup_file, original_file) - # Restore metadata in extensions.yml (hooks and installed list) - config = hook_executor.get_project_config() - if not isinstance(config, dict): - config = {} - - modified = False - - # 1. Restore hooks in extensions.yml - if not isinstance(config.get("hooks"), dict): - config["hooks"] = {} - modified = True - - # Remove any hooks for this extension added by the failed install - for hook_name in list(config["hooks"].keys()): - hooks_list = config["hooks"][hook_name] - if not isinstance(hooks_list, list): - config["hooks"][hook_name] = [] - modified = True - continue + # Restore metadata in extensions.yml (hooks and installed list). + # Only run if backup step 4 was reached (backup_hooks is not None); + # otherwise we have no safe baseline to restore from and could corrupt + # the config by removing pre-existing hooks. + if backup_hooks is not None: + config = hook_executor.get_project_config() + if not isinstance(config, dict): + config = {} - original_len = len(hooks_list) - config["hooks"][hook_name] = [ - h for h in hooks_list - if isinstance(h, dict) and h.get("extension") != extension_id - ] - if len(config["hooks"][hook_name]) != original_len: - modified = True + modified = False - # Add back the backed-up hooks - if backup_hooks: - for hook_name, hooks in backup_hooks.items(): - if not isinstance(config["hooks"].get(hook_name), list): - config["hooks"][hook_name] = [] - config["hooks"][hook_name].extend(hooks) + # 1. Restore hooks in extensions.yml + if not isinstance(config.get("hooks"), dict): + config["hooks"] = {} modified = True - # 2. Restore installed list in extensions.yml - if backup_installed is not UNSET: - if config.get("installed") != backup_installed: - config["installed"] = backup_installed - modified = True + # Remove any hooks for this extension added by the failed install + for hook_name in list(config["hooks"].keys()): + hooks_list = config["hooks"][hook_name] + if not isinstance(hooks_list, list): + config["hooks"][hook_name] = [] + modified = True + continue - if modified: - hook_executor.save_project_config(config) + original_len = len(hooks_list) + config["hooks"][hook_name] = [ + h for h in hooks_list + if isinstance(h, dict) and h.get("extension") != extension_id + ] + if len(config["hooks"][hook_name]) != original_len: + modified = True + + # Add back the backed-up hooks + if backup_hooks: + for hook_name, hooks in backup_hooks.items(): + if not isinstance(config["hooks"].get(hook_name), list): + config["hooks"][hook_name] = [] + config["hooks"][hook_name].extend(hooks) + modified = True + + # 2. Restore installed list in extensions.yml + if backup_installed is not UNSET: + if config.get("installed") != backup_installed: + config["installed"] = backup_installed + modified = True + + if modified: + hook_executor.save_project_config(config) # Restore registry entry (use restore() since entry was removed) if backup_registry_entry: From 91d10c741bf87ec2ca8fc1f927a3455285aa4bcc Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 16:52:55 +0000 Subject: [PATCH 25/30] fix(cli): move _AgentReg import outside try block; assert result.exception is None in tests --- src/specify_cli/__init__.py | 2 +- tests/test_extension_update_hardening.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 4b71d265c7..c17be7cb79 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -4838,6 +4838,7 @@ def extension_update( failed_updates = [] registrar = CommandRegistrar() hook_executor = HookExecutor(project_root) + from .agents import CommandRegistrar as _AgentReg # used in backup and rollback paths # UNSET sentinel: backup not yet captured (exception before backup step) UNSET = object() @@ -4881,7 +4882,6 @@ def extension_update( shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) # 3. Backup command files for all agents - from .agents import CommandRegistrar as _AgentReg registered_commands = backup_registry_entry.get("registered_commands", {}) if isinstance(backup_registry_entry, dict) else {} for agent_name, cmd_names in registered_commands.items(): if agent_name not in registrar.AGENT_CONFIGS: diff --git a/tests/test_extension_update_hardening.py b/tests/test_extension_update_hardening.py index a17028f61c..47b68fc511 100644 --- a/tests/test_extension_update_hardening.py +++ b/tests/test_extension_update_hardening.py @@ -42,7 +42,9 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch): # Run update result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir}) - # It might fail because of the mock zip, but must NOT raise AttributeError from config handling + # extension_update() catches exceptions internally and exits with code 1 on failure; + # result.exception will be SystemExit(1), not AttributeError. Assert both: + assert "AttributeError" not in result.output assert not isinstance(result.exception, AttributeError) def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch): @@ -67,6 +69,9 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch): result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir}) + # extension_update() catches exceptions internally — result.exception is None on any handled + # failure; assert the CLI output has no AttributeError trace + assert "AttributeError" not in result.output assert not isinstance(result.exception, AttributeError) def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch): From 2e77b19667e4cc66229e03387c2dbe8e486d33d8 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 12 May 2026 22:10:21 +0000 Subject: [PATCH 26/30] fix(extensions): consistent key order in default config; deep-copy backup_installed --- src/specify_cli/__init__.py | 6 ++++-- src/specify_cli/extensions.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 4918c1f80b..efc8f3424d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -4948,8 +4948,10 @@ def extension_update( # so no sentinel is needed to distinguish key-absent from key-empty. config = hook_executor.get_project_config() if isinstance(config, dict): - # Copy so mutations during update don't affect the backup - backup_installed = list(config.get("installed", [])) + import copy + # Deep-copy so nested mapping entries (e.g. version-pin dicts) + # are not affected by in-place mutations during the update. + backup_installed = copy.deepcopy(config.get("installed", [])) backup_hooks = {} for hook_name, hook_list in config.get("hooks", {}).items(): if not isinstance(hook_list, list): diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index d6c2d43c7c..1b9c229813 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2487,8 +2487,8 @@ def get_project_config(self) -> Dict[str, Any]: if not isinstance(result, dict): return { "installed": [], - "hooks": {}, "settings": {"auto_execute_hooks": True}, + "hooks": {}, } # Normalize nested fields so read-only callers like get_hooks_for_event() # never see non-dict hooks or non-list installed (Feedback) From 808c73a6ee0a6c53188aa6fb951180978a7eae32 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Wed, 13 May 2026 12:45:14 +0000 Subject: [PATCH 27/30] test: fix misleading comment; assert exit_code==1 in rollback test --- tests/test_extension_update_hardening.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_extension_update_hardening.py b/tests/test_extension_update_hardening.py index 047c5ed569..371fe43de6 100644 --- a/tests/test_extension_update_hardening.py +++ b/tests/test_extension_update_hardening.py @@ -1,3 +1,4 @@ +from specify_cli.extensions import ExtensionManager, ExtensionRegistry, ExtensionCatalog import pytest import yaml from typer.testing import CliRunner @@ -25,7 +26,7 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch): config_path.write_text(yaml.dump(123)) # Mock ExtensionManager to return an installed extension for resolution - from specify_cli.extensions import ExtensionManager, ExtensionCatalog, ExtensionRegistry + from specify_cli.extensions import ExtensionManager, ExtensionRegistry, ExtensionCatalog, ExtensionCatalog, ExtensionRegistry monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}]) monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) @@ -57,7 +58,7 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch): "hooks": ["not", "a", "dict"] })) - from specify_cli.extensions import ExtensionManager, ExtensionCatalog, ExtensionRegistry + from specify_cli.extensions import ExtensionManager, ExtensionRegistry, ExtensionCatalog, ExtensionCatalog, ExtensionRegistry monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}]) monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) @@ -79,11 +80,10 @@ def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch): monkeypatch.chdir(project_dir) config_path = project_dir / ".specify" / "extensions.yml" - # hooks: null should be preserved even after a failed update attempt + # Write config with hooks: null; get_project_config() normalizes this to {} + # so the backup captures {} and the restored config will have hooks: {}. config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": None})) - from specify_cli.extensions import ExtensionManager, ExtensionCatalog, ExtensionRegistry - # Mock update process to fail after backup monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}]) monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) @@ -101,6 +101,7 @@ def mock_download_fail(*args, **kwargs): result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir}) # Should handle Exception and NOT crash with AttributeError during rollback + assert result.exit_code == 1 assert "Download failed" in result.output assert not isinstance(result.exception, AttributeError) From cc37aba9268946fec509a6f6809cec84059a55bd Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Wed, 13 May 2026 12:46:25 +0000 Subject: [PATCH 28/30] test: clean up duplicate imports in hardening tests --- tests/test_extension_update_hardening.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_extension_update_hardening.py b/tests/test_extension_update_hardening.py index 371fe43de6..426e5ec7e9 100644 --- a/tests/test_extension_update_hardening.py +++ b/tests/test_extension_update_hardening.py @@ -26,7 +26,6 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch): config_path.write_text(yaml.dump(123)) # Mock ExtensionManager to return an installed extension for resolution - from specify_cli.extensions import ExtensionManager, ExtensionRegistry, ExtensionCatalog, ExtensionCatalog, ExtensionRegistry monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}]) monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) @@ -58,8 +57,6 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch): "hooks": ["not", "a", "dict"] })) - from specify_cli.extensions import ExtensionManager, ExtensionRegistry, ExtensionCatalog, ExtensionCatalog, ExtensionRegistry - monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}]) monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True}) monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"}) From 22efd6a54327116e44f8292d4911b2c2b5de8a4f Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Wed, 13 May 2026 15:11:59 +0000 Subject: [PATCH 29/30] refactor(extensions): extract _sanitize_installed_list helper; strengthen hook unregister assertion --- src/specify_cli/extensions.py | 82 +++++++++++++--------------- tests/test_extension_registration.py | 5 +- 2 files changed, 41 insertions(+), 46 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 1b9c229813..ab98c7940b 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2538,36 +2538,9 @@ def register_extension(self, extension_id: str): if not isinstance(config, dict): config = {} - # Load existing installed list, defaulting to [] if missing or corrupted raw_installed = config.get("installed") - installed = raw_installed if isinstance(raw_installed, list) else [] - - # Sanitize: keep strings and mappings with a valid 'id' - valid = [ - x for x in installed - if isinstance(x, str) or (isinstance(x, dict) and isinstance(x.get("id"), str)) - ] - - # Deduplicate by id: prefer dict (richer metadata) over plain string for - # the same id; keep only one canonical entry per id. (Feedback) - seen: dict = {} # id -> entry (dict preferred over str) - for x in valid: - eid = x if isinstance(x, str) else x.get("id", "") - if eid not in seen or isinstance(x, dict): - seen[eid] = x - - # Ensure the target extension id is present in the registry; if not - # already tracked (as a dict or string), add it as a plain string. - if extension_id not in seen: - seen[extension_id] = extension_id - - # Maintain alphabetical order by ID for readability and diff stability - def _get_sort_id(x): - return x if isinstance(x, str) else x.get("id", "") + sanitized = self._sanitize_installed_list(raw_installed, add_id=extension_id) - sanitized = sorted(seen.values(), key=_get_sort_id) - - # Always persist if sanitized state differs from raw config (ensures normalization) if sanitized != raw_installed: config["installed"] = sanitized self.save_project_config(config) @@ -2583,36 +2556,59 @@ def unregister_extension(self, extension_id: str): if not isinstance(config, dict): config = {} - # Load existing installed list, defaulting to [] if missing or corrupted raw_installed = config.get("installed") - installed = raw_installed if isinstance(raw_installed, list) else [] + sanitized = self._sanitize_installed_list(raw_installed, remove_id=extension_id) - # Sanitize, deduplicate by id, and remove the target extension. - # Mirrors register_extension(): one canonical entry per id, dict preferred over str. + # Always persist if sanitized state differs from raw config (ensures normalization) + if sanitized != raw_installed: + config["installed"] = sanitized + self.save_project_config(config) + + @staticmethod + def _sanitize_installed_list( + raw: object, + *, + add_id: str = "", + remove_id: str = "", + ) -> list: + """Normalize, deduplicate, and optionally add/remove an extension id. + + Shared by register_extension() and unregister_extension() to prevent + the two paths from drifting. + + Args: + raw: The raw value from config["installed"] (may be non-list). + add_id: If non-empty, ensure this id is present (plain-string fallback). + remove_id: If non-empty, remove this id from the list. + + Returns: + A sanitized, deduplicated, alphabetically-sorted list. + """ + installed = raw if isinstance(raw, list) else [] + + # Keep only strings and dicts with a valid string 'id' valid = [ x for x in installed if isinstance(x, str) or (isinstance(x, dict) and isinstance(x.get("id"), str)) ] + # Deduplicate by id: prefer dict (richer metadata) over plain string seen: dict = {} # id -> entry (dict preferred over str) for x in valid: eid = x if isinstance(x, str) else x.get("id", "") if eid not in seen or isinstance(x, dict): seen[eid] = x - # Remove the target extension - seen.pop(extension_id, None) + if add_id and add_id not in seen: + seen[add_id] = add_id - # Maintain alphabetical order for consistency - def _get_sort_id(x): - return x if isinstance(x, str) else x.get("id", "") + if remove_id: + seen.pop(remove_id, None) - sanitized = sorted(seen.values(), key=_get_sort_id) - - # Always persist if sanitized state differs from raw config (ensures normalization) - if sanitized != raw_installed: - config["installed"] = sanitized - self.save_project_config(config) + def _sort_key(x: object) -> str: + return x if isinstance(x, str) else x.get("id", "") # type: ignore[return-value] + + return sorted(seen.values(), key=_sort_key) def register_hooks(self, manifest: ExtensionManifest): """Register extension hooks in project config. diff --git a/tests/test_extension_registration.py b/tests/test_extension_registration.py index 6f34cf207f..e7f6956d67 100644 --- a/tests/test_extension_registration.py +++ b/tests/test_extension_registration.py @@ -151,9 +151,8 @@ def test_unregister_hooks_full_workflow(self, project_dir, tmp_path): config = executor.get_project_config() assert "hook-ext" not in config["installed"] - # Hook entry should be removed from hooks dict - if "after_tasks" in config["hooks"]: - assert len(config["hooks"]["after_tasks"]) == 0 + # Hook entry should be removed entirely; key absent or empty list both accepted + assert "after_tasks" not in config["hooks"] def test_unregister_hooks_no_hooks_key(self, project_dir): """Resilience: unregister_hooks should work even if config has no 'hooks' key.""" From ff5e1892ab3c1ac4eb412eed01f179800d32cbe8 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Wed, 13 May 2026 15:36:36 +0000 Subject: [PATCH 30/30] fix(extensions): validate extension IDs in _sanitize_installed_list; clarify test comment --- src/specify_cli/extensions.py | 23 ++++++++++++++++------- tests/test_extension_registration.py | 2 +- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index ab98c7940b..f657de06ce 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2584,22 +2584,31 @@ def _sanitize_installed_list( Returns: A sanitized, deduplicated, alphabetically-sorted list. """ + _VALID_ID = re.compile(r'^[a-z0-9-]+$') + installed = raw if isinstance(raw, list) else [] - # Keep only strings and dicts with a valid string 'id' - valid = [ - x for x in installed - if isinstance(x, str) or (isinstance(x, dict) and isinstance(x.get("id"), str)) - ] + # Keep only entries whose resolved id is a non-empty string matching + # the extension-id format (^[a-z0-9-]+$), same rule ExtensionManifest enforces. + def _valid_entry(x: object) -> bool: + if isinstance(x, str): + return bool(_VALID_ID.match(x.strip())) + if isinstance(x, dict): + eid = x.get("id") + return isinstance(eid, str) and bool(_VALID_ID.match(eid.strip())) + return False + + valid = [x for x in installed if _valid_entry(x)] # Deduplicate by id: prefer dict (richer metadata) over plain string seen: dict = {} # id -> entry (dict preferred over str) for x in valid: - eid = x if isinstance(x, str) else x.get("id", "") + eid = x.strip() if isinstance(x, str) else x.get("id", "").strip() if eid not in seen or isinstance(x, dict): seen[eid] = x - if add_id and add_id not in seen: + # Validate add_id against the same regex before inserting + if add_id and _VALID_ID.match(add_id.strip()) and add_id not in seen: seen[add_id] = add_id if remove_id: diff --git a/tests/test_extension_registration.py b/tests/test_extension_registration.py index e7f6956d67..9965deae43 100644 --- a/tests/test_extension_registration.py +++ b/tests/test_extension_registration.py @@ -151,7 +151,7 @@ def test_unregister_hooks_full_workflow(self, project_dir, tmp_path): config = executor.get_project_config() assert "hook-ext" not in config["installed"] - # Hook entry should be removed entirely; key absent or empty list both accepted + # unregister_hooks() removes the empty hook array entirely, so the key is absent assert "after_tasks" not in config["hooks"] def test_unregister_hooks_no_hooks_key(self, project_dir):