"""Installer: apply an :class:`InstallPlan` via existing primitive machinery. The actual component installation (extensions, presets, steps, workflows) is delegated to a :class:`PrimitiveInstaller` so the bundler never re-implements primitive logic (Principle I) and integration tests can inject a deterministic, offline fake (Principle II/IV). The real adapter dispatches in-process to the existing extension/preset/step/workflow machinery. Installation is idempotent and stops on first failure with no partial record write (FR-018, SC partial-failure-stop). """ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Protocol from .. import BundlerError from ..models.manifest import BundleManifest, ComponentRef from ..models.records import ( InstalledBundleRecord, components_still_needed, find_record, load_records, remove_record, save_records, upsert_record, ) from .conflict import detect_conflicts from .resolver import InstallPlan class PrimitiveInstaller(Protocol): """Adapter over the existing Spec Kit primitive install/remove machinery.""" def is_installed(self, project_root: Path, component: ComponentRef) -> bool: ... def install(self, project_root: Path, component: ComponentRef) -> None: ... def remove(self, project_root: Path, component: ComponentRef) -> None: ... @dataclass class InstallResult: bundle_id: str installed: list[ComponentRef] = field(default_factory=list) skipped: list[ComponentRef] = field(default_factory=list) refreshed: list[ComponentRef] = field(default_factory=list) uninstalled: list[ComponentRef] = field(default_factory=list) @property def changed(self) -> bool: return bool(self.installed or self.refreshed) def install_bundle( project_root: Path, plan: InstallPlan, installer: PrimitiveInstaller, manifest: BundleManifest | None = None, refresh: bool = False, ) -> InstallResult: """Execute *plan*, recording provenance. Idempotent, with bounded rollback. Atomicity is scoped, not global: on failure only the components newly installed during *this* call are rolled back, and the provenance record is written solely on full success (a failure records nothing). Components that were already installed beforehand — including those re-applied when *refresh* is True — are never rolled back. When *refresh* is True (used by ``specify bundle update``), components that are already installed are re-applied through the primitive machinery so they are brought up to the plan's pinned versions, rather than skipped. Primitive config (e.g. preset priority overrides) is preserved by the underlying machinery. 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 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. """ records = load_records(project_root) if manifest is not None: report = detect_conflicts(manifest, plan.effective_integration, records) if report.has_blocking_conflict: raise BundlerError(report.integration_clash) result = InstallResult(bundle_id=plan.bundle_id) existing = find_record(records, plan.bundle_id) prior_ours = { (c.kind, c.id) for c in existing.contributed_components } if existing is not None else set() # Components already attributed to a *different* installed bundle: these are # legitimately shareable (refcounted on removal), so this bundle may also # claim them. A component that is installed on disk but tracked by no bundle # was installed independently and must NOT be attributed here — otherwise # removing this bundle would uninstall it (collateral removal, FR-022). other_tracked = { (c.kind, c.id) for r in records if r.bundle_id != plan.bundle_id for c in r.contributed_components } contributed: list[ComponentRef] = [] done: list[ComponentRef] = [] try: for component in plan.components: key = (component.kind, component.id) if installer.is_installed(project_root, component): # A component is "ours" only when this bundle (or a sibling # bundle) already owns it. Independently-installed components # are never attributed and — crucially — never refreshed, so # ``bundle update`` cannot make collateral changes to things it # does not own (FR-022). owned = key in prior_ours or key in other_tracked if refresh and owned: _refresh_component(project_root, installer, component) result.refreshed.append(component) else: result.skipped.append(component) if owned: contributed.append(component) continue installer.install(project_root, component) done.append(component) result.installed.append(component) contributed.append(component) except BundlerError: _rollback(project_root, installer, done) raise except Exception as exc: # noqa: BLE001 _rollback(project_root, installer, done) raise BundlerError( f"Failed to install bundle '{plan.bundle_id}': {exc}. " "No changes were recorded." ) from exc record = InstalledBundleRecord.create( bundle_id=plan.bundle_id, version=plan.version, components=contributed, # Preserve the original install time across refresh/update so # ``bundle list`` keeps reporting when the bundle was first installed. installed_at=existing.installed_at if existing is not None else None, ) save_records(project_root, upsert_record(records, record)) return result def remove_bundle( project_root: Path, bundle_id: str, installer: PrimitiveInstaller, ) -> InstallResult: """Remove a bundle, uninstalling only components no other bundle still needs.""" records = load_records(project_root) target = next((r for r in records if r.bundle_id == bundle_id), None) if target is None: raise BundlerError(f"Bundle '{bundle_id}' is not installed.") still_needed = components_still_needed(records, exclude_bundle_id=bundle_id) result = InstallResult(bundle_id=bundle_id) for component in target.contributed_components: key = (component.kind, component.id) if key in still_needed: result.skipped.append(component) continue if installer.is_installed(project_root, component): installer.remove(project_root, component) result.uninstalled.append(component) else: result.skipped.append(component) save_records(project_root, remove_record(records, bundle_id)) return result def _refresh_component( project_root: Path, installer: PrimitiveInstaller, component: ComponentRef, ) -> None: """Re-apply an already-installed component to bring it up to its pinned version. Prefers a primitive-provided ``refresh`` hook when available; otherwise falls back to a re-install through the existing idempotent install path. """ op = getattr(installer, "refresh", None) if callable(op): op(project_root, component) else: installer.install(project_root, component) def _rollback( project_root: Path, installer: PrimitiveInstaller, done: list[ComponentRef], ) -> None: for component in reversed(done): try: installer.remove(project_root, component) except Exception: # noqa: BLE001 - best-effort rollback continue