Files
github-spec-kit/src/specify_cli/bundler/services/installer.py
Ali jawwad d5ba062eab fix(bundler): bundle update uninstalls components dropped by new version (#3353)
On refresh (bundle update), install_bundle iterated only the new plan's
components, so a component the previous version owned but the new one no
longer ships was left installed on disk while being dropped from the
rewritten record (contributed only holds plan.components). With no
record referencing it, remove_bundle could never clean it up —
permanently orphaned, violating the provenance invariant (FR-022). After
the component loop, when refresh and a prior record exists, uninstall
each previously-owned component absent from the new plan — unless another
bundle still needs it (components_still_needed refcount, mirroring
remove_bundle), in which case it stays installed and is simply
de-attributed. Runs inside the existing try so a failed removal takes the
same no-record-written rollback path.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:59:27 -05:00

233 lines
9.2 KiB
Python

"""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)
# On update (refresh), uninstall components this bundle used to own
# that the new version no longer ships. Otherwise they are dropped
# from the record below (contributed only holds plan.components) yet
# left on disk — permanently orphaned, since no bundle record can
# ever remove them. A stale component still owned by another bundle
# is kept installed and simply de-attributed here (it stays in that
# bundle's record). Mirrors remove_bundle's refcount logic.
if refresh and existing is not None:
planned = {(c.kind, c.id) for c in plan.components}
still_needed = components_still_needed(
records, exclude_bundle_id=plan.bundle_id
)
for component in existing.contributed_components:
key = (component.kind, component.id)
if key in planned:
continue
if key in still_needed:
continue
if installer.is_installed(project_root, component):
installer.remove(project_root, component)
result.uninstalled.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