diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 2bd33c960b..eb6ced4019 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -40,10 +40,19 @@ specify bundle install | ---------------- | ------------------------------------------------------------------ | | `--integration` | Override the integration used when initializing/installing | | `--offline` | Do not access the network | +| `--refresh` | Refresh owned components from the supplied bundle source | Installs a bundle's full component set through each primitive's machinery. The argument may be a catalog bundle id, or a local path to a built `.zip` artifact, a bundle directory, or a `bundle.yml` file; local sources install directly without consulting the catalog stack. -If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Installation is idempotent — components already present are skipped. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain. +If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Without `--refresh`, installation is idempotent — components already present are skipped. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain. + +A normal install rejects a change to an already-recorded bundle's version. To upgrade a local bundle without adding it to a catalog, pass the newer source with `--refresh`: + +```bash +specify bundle install ./new-release/bundle.yml --refresh --offline +``` + +The source may also be a bundle directory or `.zip` artifact. Refresh uses the same primitive update path as `bundle update`, re-applies components owned by a bundle, and removes previously owned components omitted from the new manifest unless another bundle still needs them. Components installed independently remain untouched and are not adopted. The success summary includes refreshed and removed counts. The bundle record advances only after the operation succeeds; as with `bundle update`, already-installed components modified during a failed refresh are not rolled back. ## Update Bundles @@ -59,7 +68,7 @@ specify bundle update [] Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed. -> **Pin enforcement is install-time only.** Idempotency checks are id-based, not version-aware: a component that is already present is skipped during `install` without comparing its on-disk version to the manifest pin. Version pins are therefore guaranteed to be applied only when the bundler actually installs a component for the first time or refreshes it. Run `specify bundle update` to re-apply every owned component at its pinned version. +> **Pin enforcement is install-time only.** Idempotency checks are id-based, not version-aware: a component that is already present is skipped during `install` without comparing its on-disk version to the manifest pin. Version pins are therefore guaranteed to be applied only when the bundler actually installs a component for the first time or refreshes it. Run `specify bundle update ` for catalog bundles or `specify bundle install --refresh` for local sources to re-apply owned components at their pinned versions. ## Remove a Bundle diff --git a/src/specify_cli/bundler/services/installer.py b/src/specify_cli/bundler/services/installer.py index 58e220638d..1e5072fd9e 100644 --- a/src/specify_cli/bundler/services/installer.py +++ b/src/specify_cli/bundler/services/installer.py @@ -80,7 +80,9 @@ def install_bundle( Version-pin enforcement is install-time only. The primitive ``is_installed`` checks are id-based (they do not compare versions), so when a component is already present and *refresh* is False it is skipped without verifying that - the on-disk version matches the manifest pin. Pins are therefore only + the on-disk version matches the manifest pin. A recorded bundle whose + resolved version changes is rejected unless *refresh* is True, preventing + the record from advancing past stale components. Pins are therefore only guaranteed to be applied when the bundler actually performs an install or a refresh; running ``specify bundle update`` re-applies every owned component at its pinned version. @@ -94,6 +96,19 @@ def install_bundle( result = InstallResult(bundle_id=plan.bundle_id) existing = find_record(records, plan.bundle_id) + if ( + existing is not None + and not refresh + and existing.version != plan.version + ): + raise BundlerError( + f"Bundle '{plan.bundle_id}' is already installed at version " + f"{existing.version}, but version {plan.version} was requested. " + "Use 'specify bundle update ' for a catalog bundle, or " + "'specify bundle install --refresh' for a local source, " + "to refresh owned components before advancing the installed record." + ) + prior_ours = { (c.kind, c.id) for c in existing.contributed_components } if existing is not None else set() diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 165f674a36..b809afba80 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -353,12 +353,16 @@ def bundle_install( ), integration: str = typer.Option(None, "--integration", help="Override integration"), offline: bool = typer.Option(False, "--offline", help="Do not access the network"), + refresh: bool = typer.Option( + False, "--refresh", help="Refresh owned components from this bundle source", + ), ) -> None: """Install a bundle's full component set through each primitive's machinery. ``bundle_id`` may be a catalog bundle id, or a local path to a built artifact (``.zip``), a bundle directory, or a ``bundle.yml`` file. Local - sources install directly without consulting the catalog stack. + sources install directly without consulting the catalog stack. Use + ``--refresh`` to update owned components from a newer local source. """ try: from ...bundler.lib.project import find_project_root @@ -428,14 +432,20 @@ def bundle_install( plan, DefaultPrimitiveInstaller(allow_network=not offline), manifest=manifest, + refresh=refresh, ) except BundlerError as exc: _fail(str(exc)) return + refresh_summary = ( + f", {len(result.refreshed)} refreshed, {len(result.uninstalled)} removed" + if refresh else "" + ) console.print( f"[green]✓[/green] Installed '{_escape_markup(str(result.bundle_id))}' " - f"({len(result.installed)} added, {len(result.skipped)} already present)." + f"({len(result.installed)} added, {len(result.skipped)} already present" + f"{refresh_summary})." ) diff --git a/tests/integration/test_bundler_install_flow.py b/tests/integration/test_bundler_install_flow.py index 0966008a74..6715eb51e4 100644 --- a/tests/integration/test_bundler_install_flow.py +++ b/tests/integration/test_bundler_install_flow.py @@ -51,6 +51,28 @@ def test_install_is_idempotent(tmp_path: Path): assert len(load_records(tmp_path)) == 1 +def test_install_rejects_version_change_without_refresh(tmp_path: Path): + """A normal install must not advance a record past stale components. + + ``bundle install`` is intentionally idempotent. When the same bundle ID + resolves to a different version, callers must use ``bundle update`` so the + owned primitives are refreshed before the record is changed. + """ + make_project(tmp_path) + installer = FakeInstaller() + + version_one = _bundle("demo", ["ext-a"], version="1.0.0") + install_bundle(tmp_path, _plan(version_one), installer, manifest=version_one) + + version_two = _bundle("demo", ["ext-a"], version="2.0.0") + with pytest.raises(BundlerError, match="bundle update"): + install_bundle(tmp_path, _plan(version_two), installer, manifest=version_two) + + record = load_records(tmp_path)[0] + assert record.version == "1.0.0" + assert len(installer.install_calls) == 1 + + def test_partial_failure_rolls_back_and_records_nothing(tmp_path: Path): make_project(tmp_path) manifest = BundleManifest.from_dict(valid_manifest_dict()) diff --git a/tests/integration/test_bundler_local_install.py b/tests/integration/test_bundler_local_install.py index 630c981a73..bf173ac10c 100644 --- a/tests/integration/test_bundler_local_install.py +++ b/tests/integration/test_bundler_local_install.py @@ -18,7 +18,7 @@ from specify_cli import app from specify_cli.bundler import BundlerError from specify_cli.commands.bundle import _local_manifest_source -from tests.bundler_helpers import make_project, valid_manifest_dict, write_manifest +from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict, write_manifest def test_local_source_none_for_non_path(): @@ -309,3 +309,76 @@ def test_incompatible_local_manifest_is_rejected_before_project_init( assert result.exit_code == 1 assert "requires Spec Kit >=999.0.0" in result.output run_init.assert_not_called() + + +@pytest.mark.parametrize("source_kind", ["manifest", "directory", "zip"]) +def test_local_install_refresh_updates_owned_components( + tmp_path: Path, monkeypatch, source_kind: str, +): + """Local upgrades refresh owned pins before advancing the bundle record.""" + from specify_cli.bundler.models.records import load_records, records_path + + project = make_project(tmp_path / "proj") + monkeypatch.chdir(project) + versions = {} + + class VersionedInstaller(FakeInstaller): + def install(self, root, component): + super().install(root, component) + versions[(component.kind, component.id)] = component.version + + def refresh(self, root, component): + assert load_records(root)[0].version == "1.2.0" + super().refresh(root, component) + versions[(component.kind, component.id)] = component.version + + installer = VersionedInstaller() + monkeypatch.setattr( + "specify_cli.bundler.services.adapters.DefaultPrimitiveInstaller", + lambda **kwargs: installer, + ) + data = valid_manifest_dict() + manifest_path = write_manifest(tmp_path / "local bundle", data) + runner = CliRunner() + first = runner.invoke(app, ["bundle", "install", str(manifest_path), "--offline"]) + assert first.exit_code == 0, first.output + original_record = records_path(project).read_bytes() + original_versions = dict(versions) + + data["bundle"]["version"] = "2.0.0" + data["provides"]["extensions"][0]["version"] = "2.0.0" + data["provides"]["presets"][0]["version"] = "3.0.0" + data["provides"]["workflows"][0]["version"] = "0.4.0" + write_manifest(manifest_path.parent, data) + if source_kind == "manifest": + source = manifest_path + elif source_kind == "directory": + source = manifest_path.parent + else: + source = tmp_path / "local bundle.zip" + with zipfile.ZipFile(source, "w") as archive: + archive.write(manifest_path, "bundle.yml") + + rejected = runner.invoke(app, ["bundle", "install", str(source), "--offline"]) + assert rejected.exit_code == 1, rejected.output + assert records_path(project).read_bytes() == original_record + assert versions == original_versions + assert installer.refresh_calls == [] + + refreshed = runner.invoke( + app, ["bundle", "install", str(source), "--offline", "--refresh"], + ) + assert refreshed.exit_code == 0, refreshed.output + assert "--refresh" in rejected.output + assert "4 refreshed" in refreshed.output + expected = { + ("extensions", "ext-a"): "2.0.0", + ("presets", "preset-a"): "3.0.0", + ("steps", "step-a"): None, + ("workflows", "wf-a"): "0.4.0", + } + assert versions == expected + assert set(installer.refresh_calls) == set(expected) + record = load_records(project)[0] + assert record.version == "2.0.0" + assert {(c.kind, c.id): c.version for c in record.contributed_components} == expected