From c36667ecb44b42d5c5bb24732a6e841fc584848f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:40:55 +0000 Subject: [PATCH 1/4] Fix bundle-update-force-mislead: add refresh() to DefaultPrimitiveInstaller Apply the remediation from the bug assessment on issue #3424. DefaultPrimitiveInstaller lacked a refresh() method, causing _refresh_component() to fall back to install(), which calls ExtensionManager.install_from_directory() with force=False. This raised ExtensionError with a leaked --force hint that bundle update does not support, leaving users with no valid recovery path. Fix: add refresh() to each kind manager (ExtensionKindManager and PresetKindManager delegate to _do_install(force=True); WorkflowKindManager and StepKindManager delegate to install() as their callables are idempotent). DefaultPrimitiveInstaller.refresh() dispatches to the kind manager's refresh(). PresetManager.install_from_directory() and install_from_zip() gain a force parameter that removes the existing preset before reinstalling, mirroring ExtensionManager's force semantics. Refs #3424 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundler/services/adapters.py | 4 + .../bundler/services/primitives.py | 32 +++++- src/specify_cli/presets/__init__.py | 16 ++- tests/unit/test_bundler_primitives.py | 102 ++++++++++++++++++ 4 files changed, 145 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/bundler/services/adapters.py b/src/specify_cli/bundler/services/adapters.py index 91e3cf1cb4..9bacb0e5a2 100644 --- a/src/specify_cli/bundler/services/adapters.py +++ b/src/specify_cli/bundler/services/adapters.py @@ -184,6 +184,10 @@ def install(self, project_root: Path, component: ComponentRef) -> None: manager = self._manager_for(component, project_root) manager.install(component) + def refresh(self, project_root: Path, component: ComponentRef) -> None: + manager = self._manager_for(component, project_root) + manager.refresh(component) + def remove(self, project_root: Path, component: ComponentRef) -> None: manager = self._manager_for(component, project_root) manager.remove(component) diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundler/services/primitives.py index 229fb61375..0e15a7bdb4 100644 --- a/src/specify_cli/bundler/services/primitives.py +++ b/src/specify_cli/bundler/services/primitives.py @@ -89,6 +89,8 @@ def is_installed(self, component: ComponentRef) -> bool: ... def install(self, component: ComponentRef) -> None: ... + def refresh(self, component: ComponentRef) -> None: ... + def remove(self, component: ComponentRef) -> None: ... @@ -151,6 +153,12 @@ def is_installed(self, component: ComponentRef) -> bool: return False def install(self, component: ComponentRef) -> None: + self._do_install(component, force=False) + + def refresh(self, component: ComponentRef) -> None: + self._do_install(component, force=True) + + def _do_install(self, component: ComponentRef, *, force: bool) -> None: from ... import get_speckit_version from ..._assets import _locate_bundled_preset @@ -168,7 +176,7 @@ def install(self, component: ComponentRef) -> None: component.version, _bundled_manifest_version(bundled / "preset.yml", "preset"), ) - self._manager.install_from_directory(bundled, speckit_version, priority) + self._manager.install_from_directory(bundled, speckit_version, priority, force=force) return if not self._allow_network: @@ -194,7 +202,7 @@ def install(self, component: ComponentRef) -> None: ) zip_path = catalog.download_pack(component.id) try: - self._manager.install_from_zip(zip_path, speckit_version, priority) + self._manager.install_from_zip(zip_path, speckit_version, priority, force=force) finally: with contextlib.suppress(Exception): if zip_path.exists(): @@ -224,6 +232,12 @@ def is_installed(self, component: ComponentRef) -> bool: return False def install(self, component: ComponentRef) -> None: + self._do_install(component, force=False) + + def refresh(self, component: ComponentRef) -> None: + self._do_install(component, force=True) + + def _do_install(self, component: ComponentRef, *, force: bool) -> None: from ... import get_speckit_version from ..._assets import _locate_bundled_extension @@ -242,7 +256,7 @@ def install(self, component: ComponentRef) -> None: _bundled_manifest_version(bundled / "extension.yml", "extension"), ) self._manager.install_from_directory( - bundled, speckit_version, priority=priority + bundled, speckit_version, priority=priority, force=force ) return @@ -272,7 +286,7 @@ def install(self, component: ComponentRef) -> None: zip_path = catalog.download_extension(component.id) try: self._manager.install_from_zip( - zip_path, speckit_version, priority=priority + zip_path, speckit_version, priority=priority, force=force ) finally: with contextlib.suppress(Exception): @@ -318,6 +332,11 @@ def install(self, component: ComponentRef) -> None: lambda: workflow_add(component.id), ) + def refresh(self, component: ComponentRef) -> None: + # workflow_add is idempotent for already-installed workflows; delegate + # to the standard install path which handles version refresh correctly. + self.install(component) + def _assert_pinned_version(self, component: ComponentRef) -> None: if not component.version: return @@ -378,6 +397,11 @@ def install(self, component: ComponentRef) -> None: lambda: workflow_step_add(component.id), ) + def refresh(self, component: ComponentRef) -> None: + # workflow_step_add is idempotent for already-installed steps; delegate + # to the standard install path which handles version refresh correctly. + self.install(component) + def remove(self, component: ComponentRef) -> None: from ... import workflow_step_remove diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 863b6ef7dc..975c81c51a 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -1503,6 +1503,7 @@ def install_from_directory( source_dir: Path, speckit_version: str, priority: int = 10, + force: bool = False, ) -> PresetManifest: """Install preset from a local directory. @@ -1510,6 +1511,7 @@ def install_from_directory( source_dir: Path to preset directory speckit_version: Current spec-kit version priority: Resolution priority (lower = higher precedence, default 10) + force: If True and the preset is already installed, remove it first Returns: Installed preset manifest @@ -1528,10 +1530,12 @@ def install_from_directory( self.check_compatibility(manifest, speckit_version) if self.registry.is_installed(manifest.id): - raise PresetError( - f"Preset '{manifest.id}' is already installed. " - f"Use 'specify preset remove {manifest.id}' first." - ) + if not force: + raise PresetError( + f"Preset '{manifest.id}' is already installed. " + f"Use 'specify preset remove {manifest.id}' first." + ) + self.remove(manifest.id) dest_dir = self.presets_dir / manifest.id if dest_dir.exists(): @@ -1622,6 +1626,7 @@ def install_from_zip( zip_path: Path, speckit_version: str, priority: int = 10, + force: bool = False, ) -> PresetManifest: """Install preset from ZIP file. @@ -1629,6 +1634,7 @@ def install_from_zip( zip_path: Path to preset ZIP file speckit_version: Current spec-kit version priority: Resolution priority (lower = higher precedence, default 10) + force: If True and the preset is already installed, remove it first Returns: Installed preset manifest @@ -1671,7 +1677,7 @@ def install_from_zip( "No preset.yml found in ZIP file" ) - return self.install_from_directory(pack_dir, speckit_version, priority) + return self.install_from_directory(pack_dir, speckit_version, priority, force=force) def remove(self, pack_id: str) -> bool: """Remove an installed preset. diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py index 9891e6f77c..cce9270121 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/unit/test_bundler_primitives.py @@ -215,3 +215,105 @@ def test_bundled_preset_pin_match_installs(tmp_path: Path, monkeypatch): manager.install(ComponentRef(kind="presets", id="my-preset", version="1.0.0")) manager.install(ComponentRef(kind="presets", id="my-preset", version=None)) assert len(called) == 2 + + +def test_extension_refresh_calls_install_with_force(tmp_path: Path, monkeypatch): + """_ExtensionKindManager.refresh() must pass force=True to install_from_directory + so an already-installed extension is overwritten instead of raising an error.""" + import specify_cli._assets as assets + from specify_cli.extensions import ExtensionManager + + bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0") + monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled) + force_values: list = [] + monkeypatch.setattr( + ExtensionManager, "install_from_directory", + lambda self, *a, **k: force_values.append(k.get("force", False)), + ) + + manager = primitive_manager("extensions", tmp_path, allow_network=False) + manager.refresh(ComponentRef(kind="extensions", id="my-ext")) + assert force_values == [True], "refresh() must pass force=True" + + +def test_preset_refresh_calls_install_with_force(tmp_path: Path, monkeypatch): + """_PresetKindManager.refresh() must pass force=True to install_from_directory + so an already-installed preset is overwritten instead of raising an error.""" + import specify_cli._assets as assets + from specify_cli.presets import PresetManager + + bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0") + monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled) + force_values: list = [] + monkeypatch.setattr( + PresetManager, "install_from_directory", + lambda self, *a, **k: force_values.append(k.get("force", False)), + ) + + manager = primitive_manager("presets", tmp_path, allow_network=False) + manager.refresh(ComponentRef(kind="presets", id="my-preset")) + assert force_values == [True], "refresh() must pass force=True" + + +def test_default_installer_refresh_dispatches_to_kind_manager(tmp_path: Path, monkeypatch): + """DefaultPrimitiveInstaller.refresh() must call the kind manager's refresh(), + which is the hook _refresh_component() will find — fixing the --force leak.""" + import specify_cli._assets as assets + from specify_cli.extensions import ExtensionManager + + bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0") + monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled) + force_values: list = [] + monkeypatch.setattr( + ExtensionManager, "install_from_directory", + lambda self, *a, **k: force_values.append(k.get("force", False)), + ) + + installer = DefaultPrimitiveInstaller(allow_network=False) + installer.refresh(tmp_path, _component("extensions", "my-ext")) + assert force_values == [True], "DefaultPrimitiveInstaller.refresh() must use force=True" + + +def test_no_force_hint_in_bundler_error_on_refresh(tmp_path: Path, monkeypatch): + """Regression: bundle update (refresh=True) of an already-installed extension + must not surface the extension-level '--force' hint inside the BundlerError.""" + from specify_cli.bundler.services.installer import install_bundle + from specify_cli.bundler.models.manifest import BundleManifest + import specify_cli._assets as assets + from specify_cli.extensions import ExtensionManager + + bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0") + monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled) + # Simulate refresh succeeding (force=True removes the duplicate-install guard) + force_seen: list = [] + monkeypatch.setattr( + ExtensionManager, "install_from_directory", + lambda self, *a, **k: force_seen.append(k.get("force", False)), + ) + + raw = { + "schema_version": "1.0", + "bundle_id": "test-bundle", + "name": "Test", + "version": "1.0.0", + "components": [{"kind": "extensions", "id": "my-ext"}], + } + manifest = BundleManifest(raw, source_id="test") + from specify_cli.bundler.services.installer import install_bundle + from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller + + installer = DefaultPrimitiveInstaller(allow_network=False) + # First install + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + # Refresh (bundle update) — must not raise with --force hint + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True) + # force=True must have been passed during the refresh call + assert True in force_seen, "refresh path should have called install_from_directory with force=True" + + +def _plan(manifest): + from specify_cli.bundler.services.installer import InstallPlan + from specify_cli.bundler.models.manifest import ComponentRef as CR + + components = [CR(kind=c.kind, id=c.id) for c in manifest.components] + return InstallPlan(bundle_id=manifest.bundle_id, version=manifest.version, components=components) From 8186783cedec291b3cc5771fcf8f0d71c236a699 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:32:35 +0000 Subject: [PATCH 2/4] Address review feedback on primitives.py and test_bundler_primitives.py - Replace ... with pass in _KindManager Protocol method stubs - Conditionally pass force= keyword only when force=True in _PresetKindManager - Fix _StepKindManager.refresh() to remove step before re-installing - Rename test to reflect actual assertion (refresh succeeds + force=True) - Remove duplicate install_bundle import Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, autonomous) --- .../bundler/services/primitives.py | 27 +++++++++++++------ tests/unit/test_bundler_primitives.py | 5 ++-- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundler/services/primitives.py index 0e15a7bdb4..46078b5ff0 100644 --- a/src/specify_cli/bundler/services/primitives.py +++ b/src/specify_cli/bundler/services/primitives.py @@ -85,13 +85,17 @@ def _bundled_manifest_version(manifest_path: Path, root_key: str) -> str | None: class _KindManager(Protocol): - def is_installed(self, component: ComponentRef) -> bool: ... + def is_installed(self, component: ComponentRef) -> bool: + pass - def install(self, component: ComponentRef) -> None: ... + def install(self, component: ComponentRef) -> None: + pass - def refresh(self, component: ComponentRef) -> None: ... + def refresh(self, component: ComponentRef) -> None: + pass - def remove(self, component: ComponentRef) -> None: ... + def remove(self, component: ComponentRef) -> None: + pass def primitive_manager( @@ -176,7 +180,9 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: component.version, _bundled_manifest_version(bundled / "preset.yml", "preset"), ) - self._manager.install_from_directory(bundled, speckit_version, priority, force=force) + self._manager.install_from_directory( + bundled, speckit_version, priority, **({"force": True} if force else {}) + ) return if not self._allow_network: @@ -202,7 +208,9 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: ) zip_path = catalog.download_pack(component.id) try: - self._manager.install_from_zip(zip_path, speckit_version, priority, force=force) + self._manager.install_from_zip( + zip_path, speckit_version, priority, **({"force": True} if force else {}) + ) finally: with contextlib.suppress(Exception): if zip_path.exists(): @@ -398,8 +406,11 @@ def install(self, component: ComponentRef) -> None: ) def refresh(self, component: ComponentRef) -> None: - # workflow_step_add is idempotent for already-installed steps; delegate - # to the standard install path which handles version refresh correctly. + # workflow_step_add errors when the step is already installed; remove it + # first (when network is permitted so the re-install can follow) before + # delegating to install(). + if self._allow_network and self.is_installed(component): + self.remove(component) self.install(component) def remove(self, component: ComponentRef) -> None: diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py index cce9270121..6d689598ef 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/unit/test_bundler_primitives.py @@ -274,9 +274,9 @@ def test_default_installer_refresh_dispatches_to_kind_manager(tmp_path: Path, mo assert force_values == [True], "DefaultPrimitiveInstaller.refresh() must use force=True" -def test_no_force_hint_in_bundler_error_on_refresh(tmp_path: Path, monkeypatch): +def test_refresh_succeeds_and_passes_force_true(tmp_path: Path, monkeypatch): """Regression: bundle update (refresh=True) of an already-installed extension - must not surface the extension-level '--force' hint inside the BundlerError.""" + must succeed and pass force=True to install_from_directory.""" from specify_cli.bundler.services.installer import install_bundle from specify_cli.bundler.models.manifest import BundleManifest import specify_cli._assets as assets @@ -299,7 +299,6 @@ def test_no_force_hint_in_bundler_error_on_refresh(tmp_path: Path, monkeypatch): "components": [{"kind": "extensions", "id": "my-ext"}], } manifest = BundleManifest(raw, source_id="test") - from specify_cli.bundler.services.installer import install_bundle from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller installer = DefaultPrimitiveInstaller(allow_network=False) From b26d97ae83e60a2819c71ef82416e9f18495e03c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:23:06 +0000 Subject: [PATCH 3/4] fix: add missing role/effective_integration to InstallPlan in _plan() and remove redundant import - Remove duplicate `DefaultPrimitiveInstaller` import inside test body (already imported at module scope on line 15) - Add required `role` and `effective_integration` fields to `InstallPlan` constructor in `_plan()` helper to prevent TypeError at runtime Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) --- tests/unit/test_bundler_primitives.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py index 6d689598ef..3b6f63ee55 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/unit/test_bundler_primitives.py @@ -299,8 +299,6 @@ def test_refresh_succeeds_and_passes_force_true(tmp_path: Path, monkeypatch): "components": [{"kind": "extensions", "id": "my-ext"}], } manifest = BundleManifest(raw, source_id="test") - from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller - installer = DefaultPrimitiveInstaller(allow_network=False) # First install install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) @@ -315,4 +313,10 @@ def _plan(manifest): from specify_cli.bundler.models.manifest import ComponentRef as CR components = [CR(kind=c.kind, id=c.id) for c in manifest.components] - return InstallPlan(bundle_id=manifest.bundle_id, version=manifest.version, components=components) + return InstallPlan( + bundle_id=manifest.bundle_id, + version=manifest.version, + role=manifest.bundle.role, + effective_integration=None, + components=components, + ) From 13d4f41b648f995844d1e3f68f0dbed908ddb816 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:28:46 +0000 Subject: [PATCH 4/4] fix: address latest PR review comments Assisted-by: GitHub Copilot (model: gpt-5.6-terra, autonomous) --- .../bundler/services/primitives.py | 31 ++++++++++++--- tests/unit/test_bundler_primitives.py | 38 +++++++++++++------ 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundler/services/primitives.py index 46078b5ff0..31b1126a34 100644 --- a/src/specify_cli/bundler/services/primitives.py +++ b/src/specify_cli/bundler/services/primitives.py @@ -406,12 +406,33 @@ def install(self, component: ComponentRef) -> None: ) def refresh(self, component: ComponentRef) -> None: - # workflow_step_add errors when the step is already installed; remove it - # first (when network is permitted so the re-install can follow) before - # delegating to install(). - if self._allow_network and self.is_installed(component): + # Preserve an existing step until we've validated we can perform refresh. + # For already-installed steps, keep a backup and restore it if the + # remove+reinstall path fails. + if not (self._allow_network and self.is_installed(component)): + self.install(component) + return + + import shutil + import tempfile + + step_dir = self._registry.steps_dir / component.id + metadata = self._registry.get(component.id) + backup_dir = Path(tempfile.mkdtemp(prefix="speckit-step-refresh-")) / component.id + try: + if step_dir.exists(): + shutil.copytree(step_dir, backup_dir) self.remove(component) - self.install(component) + try: + self.install(component) + except BundlerError: + if backup_dir.exists(): + shutil.copytree(backup_dir, step_dir, dirs_exist_ok=True) + if metadata is not None and not self._registry.is_installed(component.id): + self._registry.add(component.id, metadata) + raise + finally: + shutil.rmtree(backup_dir.parent, ignore_errors=True) def remove(self, component: ComponentRef) -> None: from ... import workflow_step_remove diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py index 3b6f63ee55..dc39106b50 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/unit/test_bundler_primitives.py @@ -20,6 +20,7 @@ _WorkflowKindManager, primitive_manager, ) +from tests.bundler_helpers import valid_manifest_dict def _component(kind: str, cid: str = "x") -> ComponentRef: @@ -286,19 +287,32 @@ def test_refresh_succeeds_and_passes_force_true(tmp_path: Path, monkeypatch): monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled) # Simulate refresh succeeding (force=True removes the duplicate-install guard) force_seen: list = [] + def _fake_install_from_directory(self, *a, **k): + force_seen.append(k.get("force", False)) + self.registry.add("my-ext", {"version": "1.0.0"}) + monkeypatch.setattr( - ExtensionManager, "install_from_directory", - lambda self, *a, **k: force_seen.append(k.get("force", False)), + ExtensionManager, "install_from_directory", _fake_install_from_directory ) - raw = { - "schema_version": "1.0", - "bundle_id": "test-bundle", - "name": "Test", - "version": "1.0.0", - "components": [{"kind": "extensions", "id": "my-ext"}], - } - manifest = BundleManifest(raw, source_id="test") + raw = valid_manifest_dict( + bundle={ + "id": "test-bundle", + "name": "Test", + "version": "1.0.0", + "role": "developer", + "description": "Test bundle", + "author": "Spec Kit", + "license": "MIT", + }, + provides={ + "extensions": [{"id": "my-ext", "version": "1.0.0"}], + "presets": [], + "steps": [], + "workflows": [], + }, + ) + manifest = BundleManifest.from_dict(raw) installer = DefaultPrimitiveInstaller(allow_network=False) # First install install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) @@ -314,8 +328,8 @@ def _plan(manifest): components = [CR(kind=c.kind, id=c.id) for c in manifest.components] return InstallPlan( - bundle_id=manifest.bundle_id, - version=manifest.version, + bundle_id=manifest.bundle.id, + version=manifest.bundle.version, role=manifest.bundle.role, effective_integration=None, components=components,