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
13 changes: 11 additions & 2 deletions docs/reference/bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,19 @@ 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. 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

Expand All @@ -59,7 +68,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
17 changes: 16 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. 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.
Expand All @@ -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 <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
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
22 changes: 22 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,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())
Expand Down
75 changes: 74 additions & 1 deletion tests/integration/test_bundler_local_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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