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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.6.1] - 2026-05-25

### Fixed

- **`bcli pack install` now writes registry presets in the array shape the
runtime registry loader reads.** The installer previously stored
`registries/<profile>.json` `endpoints` as a name→body object, but
`EndpointRegistry` iterates `endpoints` as a JSON array — so pack-installed
presets never resolved at query time (and merging into an existing
array-shaped registry raised `InstallError`). Install and uninstall now
read/merge/filter the array by each entry's `entity_set_name`. Added a
regression test that loads an installed preset through the real registry
loader and resolves it.

## [0.6.0] - 2026-05-25

### Changed — ETL stampers are now pluggable (BREAKING)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ build-backend = "hatchling.build"
# installed CLI binary (`bcli`) are unaffected — only `pip install` /
# `uv tool install` use this name.
name = "bc-cli"
version = "0.6.0"
version = "0.6.1"
description = "Python SDK and CLI for Microsoft Dynamics 365 Business Central APIs"
readme = "README.md"
license = "Apache-2.0"
Expand Down
37 changes: 29 additions & 8 deletions src/bcli/packs/_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,13 +492,31 @@ def execute_install(
raise InstallError(
f"existing {rpath} is not valid JSON: {e}"
) from e
endpoints = existing_reg.get("endpoints") or {}
if not isinstance(endpoints, dict):
# The runtime registry loader (`bcli.registry._registry`) reads
# `endpoints` as a JSON array of entry objects, each keyed by its
# `entity_set_name`. Write that shape — not a name→body map — so
# pack-installed presets are actually resolvable at query time.
endpoints = existing_reg.get("endpoints") or []
if not isinstance(endpoints, list):
raise InstallError(
f"{rpath}: 'endpoints' must be an object"
f"{rpath}: 'endpoints' must be a JSON array"
)
index_by_name: dict[str, int] = {}
for i, entry in enumerate(endpoints):
if isinstance(entry, dict):
key = entry.get("entity_set_name")
if key:
index_by_name[key] = i
for preset in plan.preset_writes:
endpoints[preset.name] = preset.body
body = dict(preset.body)
body.setdefault("entity_set_name", preset.name)
key = body["entity_set_name"]
existing_idx = index_by_name.get(key)
if existing_idx is not None:
endpoints[existing_idx] = body
else:
index_by_name[key] = len(endpoints)
endpoints.append(body)
registry_entries.append(LedgerRegistryEntry(
name=preset.name,
rendered_hash=preset.rendered_hash,
Expand Down Expand Up @@ -674,10 +692,13 @@ def uninstall_pack(
raw = json.loads(rpath.read_text(encoding="utf-8"))
except json.JSONDecodeError:
raw = {}
endpoints = raw.get("endpoints") or {}
for _, name in preset_keys:
if name in endpoints:
del endpoints[name]
endpoints = raw.get("endpoints") or []
if isinstance(endpoints, list):
remove = {name for _, name in preset_keys}
endpoints = [
e for e in endpoints
if not (isinstance(e, dict) and e.get("entity_set_name") in remove)
]
raw["endpoints"] = endpoints
_atomic_write(rpath, json.dumps(raw, indent=2))

Expand Down
46 changes: 41 additions & 5 deletions tests/test_packs/test_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,11 @@ def test_install_writes_all_artefacts(make_pack, config_dir, install_target) ->
rpath = registries_path("prod", override=config_dir)
assert rpath.is_file()
reg = json.loads(rpath.read_text())
assert "myEntity" in reg["endpoints"]
assert reg["endpoints"]["myEntity"]["source_pack"] == "demo"
assert reg["endpoints"]["myEntity"]["pack_version"] == "0.1.0"
assert isinstance(reg["endpoints"], list)
by_name = {e["entity_set_name"]: e for e in reg["endpoints"]}
assert "myEntity" in by_name
assert by_name["myEntity"]["source_pack"] == "demo"
assert by_name["myEntity"]["pack_version"] == "0.1.0"

# Ledger persisted.
ledger = read_ledger("demo", "prod", config_dir=config_dir)
Expand All @@ -77,6 +79,38 @@ def test_install_writes_all_artefacts(make_pack, config_dir, install_target) ->
assert any(p.kind == "agents_block" for p in ledger.paths)


def test_installed_preset_is_loadable_by_runtime_registry(
make_pack, config_dir, install_target
) -> None:
"""Regression: the installer must write the `endpoints` array shape the
runtime registry loader reads, so a pack-installed preset actually
resolves at query time (not just round-trips through the installer)."""
from bcli.registry._registry import EndpointRegistry

src = make_pack(
"demo",
presets={"myEntity": {
"entity_set_name": "myEntity",
"supports": ["GET"],
"api_publisher": "acme",
"api_group": "ops",
"api_version": "v1.0",
}},
)
install_pack(
load_pack(src), profile="prod", target=install_target, dry_run=False,
config_override=config_dir,
)
rpath = registries_path("prod", override=config_dir)

reg = EndpointRegistry(disable_standard=True)
count = reg.load_custom_from_file(rpath)

assert count == 1
meta = reg.resolve("myEntity")
assert meta.entity_set_name == "myEntity"


def test_fragment_targets_route_blocks(make_pack, config_dir, install_target) -> None:
src = make_pack(
"tgts",
Expand Down Expand Up @@ -165,7 +199,8 @@ def test_conflict_blocks_second_pack(make_pack, config_dir, install_target) -> N
config_override=config_dir,
)
reg = json.loads(registries_path("prod", override=config_dir).read_text())
assert reg["endpoints"]["shared"]["source_pack"] == "beta"
by_name = {e["entity_set_name"]: e for e in reg["endpoints"]}
assert by_name["shared"]["source_pack"] == "beta"


def test_uninstall_removes_artefacts(make_pack, config_dir, install_target) -> None:
Expand Down Expand Up @@ -199,7 +234,8 @@ def test_uninstall_removes_artefacts(make_pack, config_dir, install_target) -> N
assert "bcli-pack:demo:a.md START" not in agents
# Preset removed.
reg = json.loads(registries_path("prod", override=config_dir).read_text())
assert "epX" not in (reg.get("endpoints") or {})
names = {e.get("entity_set_name") for e in (reg.get("endpoints") or [])}
assert "epX" not in names
# Ledger gone.
assert read_ledger("demo", "prod", config_dir=config_dir) is None
# No catastrophic warnings on a clean install/uninstall.
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading