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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/reference/bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ specify bundle install <bundle_id | path>

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. 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.
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. Components installed outside any bundle are skipped and never adopted, so their installed version must match the manifest pin; if it doesn't, or can't be read, install and refresh stop before changing anything and name the component, so you can remove it or install the pinned version yourself. 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 or owned component metadata (version, source, preset priority, or strategy), including removal of an owned component. This applies even if a local manifest keeps the same bundle version. Reordering unchanged components or adding new components does not require refresh. To apply changes to a local bundle without adding it to a catalog, pass the revised source with `--refresh`:

Expand All @@ -95,7 +95,7 @@ specify bundle update [<bundle_id>]

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 <bundle_id>` for catalog bundles or `specify bundle install <path> --refresh` for local sources to re-apply owned components at their pinned versions.
> **Pin enforcement is install-time only.** Idempotency checks are id-based, not version-aware: a component owned by a bundle 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 <bundle_id>` for catalog bundles or `specify bundle install <path> --refresh` for local sources to re-apply owned components at their pinned versions.

## Remove a Bundle

Expand Down
6 changes: 6 additions & 0 deletions src/specify_cli/bundles/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,12 @@ def is_installed(self, project_root: Path, component: ComponentRef) -> bool:
manager = self._manager_for(component, project_root)
return manager.is_installed(component)

def installed_version(
self, project_root: Path, component: ComponentRef
) -> str | None:
manager = self._manager_for(component, project_root)
return manager.installed_version(component)

def install(self, project_root: Path, component: ComponentRef) -> None:
manager = self._manager_for(component, project_root)
manager.install(component)
Expand Down
45 changes: 44 additions & 1 deletion src/specify_cli/bundles/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
)
from .conflict import detect_conflicts
from .resolver import InstallPlan
from .versioning import same_version


class PrimitiveInstaller(Protocol):
Expand Down Expand Up @@ -86,6 +87,10 @@ def install_bundle(
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.
The exception is a component installed independently of any bundle: it is
never installed or refreshed here, so its installed version must already
match the pin, or the call fails before changing anything.
"""
records = load_records(project_root)

Expand Down Expand Up @@ -127,10 +132,10 @@ def install_bundle(
if r.bundle_id != plan.bundle_id
for c in r.contributed_components
}

contributed: list[ComponentRef] = []
done: list[ComponentRef] = []
try:
_check_unowned_pins(project_root, plan, installer, prior_ours | other_tracked)
for component in plan.components:
key = (component.kind, component.id)
if installer.is_installed(project_root, component):
Expand Down Expand Up @@ -247,6 +252,44 @@ def remove_bundle(
return result


def _check_unowned_pins(
project_root: Path,
plan: InstallPlan,
installer: PrimitiveInstaller,
owned: set[tuple[str, str]],
) -> None:
"""Refuse to skip an independently installed component that misses its pin.
A component tracked by no bundle is skipped and never refreshed (FR-022), so
skipping it is only correct when it already has the pinned version.
Otherwise the bundle record would advance while the project keeps running a
different version (#4434). Runs before any primitive is touched. A component
whose installed version can't be read fails too, since it can't be shown to
match. Installers without an ``installed_version`` hook are not checked.
"""
installed_version = getattr(installer, "installed_version", None)
if not callable(installed_version):
return
mismatches = []
for component in plan.components:
if not component.version or (component.kind, component.id) in owned:
continue
if not installer.is_installed(project_root, component):
continue
actual = installed_version(project_root, component)
pinned = f"{component.kind[:-1]} '{component.id}' to {component.version}"
if actual is None:
mismatches.append(f"{pinned}, but its installed version is unknown")
elif not same_version(actual, component.version):
mismatches.append(f"{pinned}, but {actual} is installed")
if mismatches:
raise BundlerError(
f"Bundle '{plan.bundle_id}' pins {'; '.join(mismatches)}. Bundles "
"leave components installed outside any bundle unchanged, so remove "
"the installed version or install the pinned one yourself, then re-run."
)


def _refresh_component(
project_root: Path,
installer: PrimitiveInstaller,
Expand Down
37 changes: 31 additions & 6 deletions src/specify_cli/bundles/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,9 @@ def _assert_pinned_version(
actual = str(advertised).strip()
if not actual:
return
from .versioning import parse_version
from .versioning import same_version

try:
matches = parse_version(actual) == parse_version(pinned)
except BundlerError:
matches = actual == str(pinned).strip()
if not matches:
if not same_version(actual, pinned):
raise BundlerError(
f"{kind} '{component_id}' is pinned to version {pinned} in the bundle "
f"manifest, but the resolved version is {actual}. Update the bundle's "
Expand Down Expand Up @@ -84,10 +80,27 @@ def _bundled_manifest_version(manifest_path: Path, root_key: str) -> str | None:
return None


def _registry_version(registry, component_id: str) -> str | None:
"""Version a primitive registry recorded for an installed component.
Returns ``None`` when there is no entry, the registry is unreadable, or the
entry has no usable version, meaning the installed version is unknown.
"""
try:
entry = registry.get(component_id)
except Exception: # noqa: BLE001 - unreadable registry: version unknown
return None
version = entry.get("version") if isinstance(entry, dict) else None
return version if isinstance(version, str) and version.strip() else None


class _KindManager(Protocol):
def is_installed(self, component: ComponentRef) -> bool:
pass

def installed_version(self, component: ComponentRef) -> str | None:
pass

def install(self, component: ComponentRef) -> None:
pass

Expand Down Expand Up @@ -156,6 +169,9 @@ def is_installed(self, component: ComponentRef) -> bool:
except Exception: # noqa: BLE001
return False

def installed_version(self, component: ComponentRef) -> str | None:
return _registry_version(self._manager.registry, component.id)

def install(self, component: ComponentRef) -> None:
self._do_install(component, force=False)

Expand Down Expand Up @@ -243,6 +259,9 @@ def is_installed(self, component: ComponentRef) -> bool:
except Exception: # noqa: BLE001
return False

def installed_version(self, component: ComponentRef) -> str | None:
return _registry_version(self._manager.registry, component.id)

def install(self, component: ComponentRef) -> None:
self._do_install(component, force=False)

Expand Down Expand Up @@ -334,6 +353,9 @@ def is_installed(self, component: ComponentRef) -> bool:
except Exception: # noqa: BLE001
return False

def installed_version(self, component: ComponentRef) -> str | None:
return _registry_version(self._registry, component.id)

def install(self, component: ComponentRef) -> None:
from .._assets import _locate_bundled_workflow

Expand Down Expand Up @@ -424,6 +446,9 @@ def is_installed(self, component: ComponentRef) -> bool:
except Exception: # noqa: BLE001
return False

def installed_version(self, component: ComponentRef) -> str | None:
return _registry_version(self._registry, component.id)

def install(self, component: ComponentRef) -> None:
if not self._allow_network:
raise BundlerError(
Expand Down
12 changes: 12 additions & 0 deletions src/specify_cli/bundles/versioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ def satisfies(installed: str, constraint: str) -> bool:
return spec.contains(version, prereleases=True)


def same_version(actual: str, pinned: str) -> bool:
"""Return True if *actual* is the exact version *pinned* names.
Compares parsed versions (``v1.0.0`` matches ``1.0.0``) and falls back to a
plain string comparison when either side does not parse.
"""
try:
return parse_version(actual) == parse_version(pinned)
except BundlerError:
return str(actual).strip() == str(pinned).strip()


_SEMVER_RE = re.compile(
r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
r"(?:-(?:(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
Expand Down
6 changes: 6 additions & 0 deletions tests/specify_cli/bundles/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ def __init__(self, *, fail_on: str | None = None) -> None:
self.install_calls: list[tuple[str, str]] = []
self.remove_calls: list[tuple[str, str]] = []
self.refresh_calls: list[tuple[str, str]] = []
# Installed versions reported by ``installed_version``; set by tests
# that pre-install a component at a specific version.
self.versions: dict[tuple[str, str], str] = {}
self._fail_on = fail_on

def _key(self, component: ComponentRef) -> tuple[str, str]:
Expand All @@ -126,6 +129,9 @@ def _key(self, component: ComponentRef) -> tuple[str, str]:
def is_installed(self, project_root: Path, component: ComponentRef) -> bool:
return self._key(component) in self.installed

def installed_version(self, project_root: Path, component: ComponentRef) -> str | None:
return self.versions.get(self._key(component))

def install(self, project_root: Path, component: ComponentRef) -> None:
from specify_cli.bundler import BundlerError

Expand Down
39 changes: 39 additions & 0 deletions tests/specify_cli/bundles/test_command_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import os
import shutil
import zipfile
from pathlib import Path
from unittest.mock import patch
Expand Down Expand Up @@ -86,6 +87,44 @@ def test_local_bundle_refuses_unbundled_workflow_offline(project: Path):
assert "network access is disabled" in " ".join(result.output.lower().split())


def test_local_bundle_refuses_independently_installed_extension_at_other_version(
project: Path,
):
from specify_cli.bundles.records import records_path

older = project / "bug-older"
shutil.copytree(REPO_ROOT / "extensions" / "bug", older)
ext_manifest = yaml.safe_load((older / "extension.yml").read_text(encoding="utf-8"))
ext_manifest["extension"]["version"] = "0.0.1"
(older / "extension.yml").write_text(yaml.safe_dump(ext_manifest), encoding="utf-8")
added = runner.invoke(app, ["extension", "add", str(older), "--dev"])
assert added.exit_code == 0, added.output

pinned = bundled_extension_version("bug")
bundle_dir = project / "pins-bug"
bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text(
yaml.safe_dump(
valid_manifest_dict(
provides={"extensions": [{"id": "bug", "version": pinned}]}
)
),
encoding="utf-8",
)

result = runner.invoke(app, ["bundle", "install", str(bundle_dir), "--offline"])

assert result.exit_code == 1
assert f"extension 'bug' to {pinned}, but 0.0.1 is installed" in " ".join(
result.output.split()
)
assert not records_path(project).exists()
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
assert registry["extensions"]["bug"]["version"] == "0.0.1"


def test_install_refuses_discovery_only_source(project: Path, monkeypatch):
# Point a discovery-only catalog at a local payload containing the bundle.
catalog = project / "disc.json"
Expand Down
Loading
Loading