Skip to content
Open
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
15 changes: 13 additions & 2 deletions docs/reference/bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,21 @@ specify bundle install <bundle_id | path>
| ---------------- | ------------------------------------------------------------------ |
| `--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 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`:

```bash
specify bundle install ./new-release/bundle.yml --refresh
```

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.

A local bundle source supplies the manifest, not its component payloads. Components resolved through catalogs still require network access to refresh, even when already installed. Add `--offline` only when the components being installed or refreshed ship with Spec Kit; otherwise the command reports which component needs network access. Re-run without `--offline` to fetch that component through its catalog.

## Update Bundles

Expand All @@ -59,7 +70,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` 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 <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
21 changes: 20 additions & 1 deletion src/specify_cli/bundler/services/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. Changes to a recorded bundle's
version or owned component metadata, including removals, are rejected unless
*refresh* is True, preventing stale or orphaned components. Pins are 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.
Expand All @@ -94,6 +96,23 @@ 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
or not set(existing.contributed_components).issubset(plan.components)
)
):
Comment thread
Copilot marked this conversation as resolved.
raise BundlerError(
f"Bundle '{plan.bundle_id}' is already installed at version "
f"{existing.version}, but the requested manifest changes the bundle "
"version or changes/removes owned components. "
"Use 'specify bundle update <id>' for a catalog bundle, or "
"'specify bundle install <path> --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()
Expand Down
16 changes: 8 additions & 8 deletions src/specify_cli/bundler/services/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None:
if not self._allow_network:
raise BundlerError(
f"Preset '{component.id}' is not bundled and network access is "
f"disabled; re-run without --offline or install it first with "
f"'specify preset add {component.id}'."
"disabled. Installing or refreshing this component requires "
"network access; re-run without --offline."
)

from ...presets import PresetCatalog
Expand Down Expand Up @@ -272,8 +272,8 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None:
if not self._allow_network:
raise BundlerError(
f"Extension '{component.id}' is not bundled and network access is "
f"disabled; re-run without --offline or install it first with "
f"'specify extension add {component.id}'."
"disabled. Installing or refreshing this component requires "
"network access; re-run without --offline."
)

from ...extensions import ExtensionCatalog
Expand Down Expand Up @@ -330,8 +330,8 @@ def install(self, component: ComponentRef) -> None:
if not self._allow_network and not self._is_bundled(component.id):
raise BundlerError(
f"Workflow '{component.id}' installs from a catalog and network "
f"access is disabled; re-run without --offline or install it first "
f"with 'specify workflow add {component.id}'."
"access is disabled. Installing or refreshing this component "
"requires network access; re-run without --offline."
)
self._assert_pinned_version(component)
from ... import workflow_add
Expand Down Expand Up @@ -396,8 +396,8 @@ def install(self, component: ComponentRef) -> None:
if not self._allow_network:
raise BundlerError(
f"Step '{component.id}' installs from a catalog and network access "
f"is disabled; re-run without --offline or install it first with "
f"'specify workflow step add {component.id}'."
"is disabled. Installing or refreshing this component requires "
"network access; re-run without --offline."
)
from ... import workflow_step_add

Expand Down
14 changes: 12 additions & 2 deletions src/specify_cli/commands/bundle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})."
)


Expand Down
100 changes: 100 additions & 0 deletions tests/integration/test_bundler_install_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,106 @@ 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


@pytest.mark.parametrize("kind,updates", [
("extensions", {"version": "2.0.0"}),
("presets", {"version": "3.0.0"}),
("steps", {"version": "1.0.0"}),
("workflows", {"version": "0.4.0"}),
("extensions", {"source": "https://example.com/catalog.json"}),
("presets", {"priority": 20}),
("presets", {"strategy": "prepend"}),
("extensions", None),
("presets", None),
("steps", None),
("workflows", None),
])
def test_install_requires_refresh_for_owned_component_changes(
tmp_path: Path, kind: str, updates: dict | None,
):
make_project(tmp_path)
data = valid_manifest_dict()
original = BundleManifest.from_dict(data)
installer = FakeInstaller()
install_bundle(tmp_path, _plan(original), installer, manifest=original)
original_record = records_path(tmp_path).read_bytes()
original_installed = set(installer.installed)
installer.install_calls.clear()

component_id = data["provides"][kind][0]["id"]
if updates is None:
data["provides"][kind] = []
else:
data["provides"][kind][0].update(updates)
# Even a new component ordered before the changed one must not be installed.
data["provides"]["extensions"].insert(0, {"id": "ext-new", "version": "1.0.0"})
changed = BundleManifest.from_dict(data)
plan = _plan(changed)

with pytest.raises(BundlerError, match="--refresh"):
install_bundle(tmp_path, plan, installer, manifest=changed)

assert records_path(tmp_path).read_bytes() == original_record
assert installer.installed == original_installed
assert installer.install_calls == []
assert installer.refresh_calls == []
assert installer.remove_calls == []

result = install_bundle(tmp_path, plan, installer, manifest=changed, refresh=True)
record = load_records(tmp_path)[0]
assert record.version == original.bundle.version
assert record.contributed_components == tuple(plan.components)
assert {(c.kind, c.id) for c in result.installed} == {("extensions", "ext-new")}
if updates is None:
assert installer.remove_calls == [(kind, component_id)]
assert (kind, component_id) not in installer.installed
else:
assert (kind, component_id) in installer.refresh_calls
assert installer.remove_calls == []


def test_install_allows_reordered_components_and_additions(tmp_path: Path):
make_project(tmp_path)
data = valid_manifest_dict()
data["provides"]["extensions"].append({"id": "ext-b", "version": "1.0.0"})
original = BundleManifest.from_dict(data)
installer = FakeInstaller()
install_bundle(tmp_path, _plan(original), installer, manifest=original)

data["provides"]["extensions"].reverse()
# Identity includes kind: a step can have the same ID as an extension.
data["provides"]["steps"].append({"id": "ext-a"})
changed = BundleManifest.from_dict(data)
plan = _plan(changed)
result = install_bundle(tmp_path, plan, installer, manifest=changed)

assert {(c.kind, c.id) for c in result.installed} == {("steps", "ext-a")}
assert len(result.skipped) == 5
assert installer.refresh_calls == []
assert load_records(tmp_path)[0].contributed_components == tuple(plan.components)


def test_partial_failure_rolls_back_and_records_nothing(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
Expand Down
Loading