diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 95023189..5a3ad7d7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.63.0", + "version": "0.63.1", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de1f1fdc..fa88e1fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,6 +83,40 @@ jobs: fi echo "OK: $expected matches tag $tag" + - name: Build the legacy-named compat wheel (self-update bridge for <=0.62 clients) + # MIGRATION BRIDGE (#424). Pre-0.63 clients were installed as the + # `keboola-agent-cli` distribution. Their (immutable, already-shipped) + # self-update code builds the asset URL with the OLD wheel name + # `keboola_agent_cli--py3-none-any.whl` and runs + # uv tool install --force "keboola-agent-cli[...] @ " + # After the PyPI rename the release only carried `keboola_cli-*.whl`, so + # that HEAD probe 404'd, the client fell back to the git+ build, and uv + # aborted with `Executable already exists: kbagent` -- self-update + # silently failed for every <=0.62 user. + # + # Shipping a SECOND wheel whose DISTRIBUTION name is still + # `keboola-agent-cli` (identical code; only the [project].name differs) + # lets those clients find their asset and upgrade in place. Dynamic + # APP_NAME (constants.py) keeps `kbagent version` / User-Agent working + # under either distribution name. Remove this step (and the verify + # below) once the <=0.62 fleet has migrated off the legacy name. + run: | + cp pyproject.toml pyproject.toml.orig + sed -i 's/^name = "keboola-cli"$/name = "keboola-agent-cli"/' pyproject.toml + uv build --wheel + mv pyproject.toml.orig pyproject.toml + + - name: Verify the legacy compat wheel exists + run: | + tag="$TAG" + version="${tag#v}" + expected="dist/keboola_agent_cli-${version}-py3-none-any.whl" + if [ ! -f "$expected" ]; then + echo "::error::Expected legacy compat wheel $expected but built: $(ls dist/)" + exit 1 + fi + echo "OK: legacy compat wheel $expected present" + - name: Upload the wheel to the release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 1591d8c0..d92e5d8b 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.63.0", + "version": "0.63.1", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/pyproject.toml b/pyproject.toml index 8c523488..61e24a74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.63.0" +version = "0.63.1" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index b0689d01..080c2a97 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,25 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.63.1": [ + "Fix (#424): self-update is repaired for users still on <=0.62.0. The PyPI rename " + "`keboola-agent-cli` -> `keboola-cli` broke `kbagent update` for every already-installed " + "client: the immutable pre-0.63 code probes the release for the OLD wheel name " + "`keboola_agent_cli--py3-none-any.whl`, which the renamed release no longer " + "carried (404), then falls back to a `git+` build that uv aborts with `Executable already " + "exists: kbagent`. The release workflow now ALSO ships a legacy-named compat wheel " + "(`keboola_agent_cli--py3-none-any.whl`, identical code, distribution name " + "unchanged) so those clients find their asset and upgrade in place. `APP_NAME` is now " + "resolved dynamically (prefers `keboola-cli`, falls back to `keboola-agent-cli`) so " + "`kbagent version` and the User-Agent keep working under either distribution. The on-disk " + "config dir (`~/.config/keboola-agent-cli/`) is deliberately unchanged.", + "Fix: a FAILED `kbagent update` is no longer reported as `already up to date`. " + "`_compose_update_summary` masked any non-upgraded stage as success, so the self-update " + "breakage above surfaced to users as `kbagent vX (already up to date)` while `kbagent " + "version` correctly showed a newer release available. Failures now render as " + "`kbagent vX update FAILED: ` (full transcript stays in `--json` / `--verbose` " + "output); only an explicit `up_to_date` short-circuit prints the up-to-date line.", + ], "0.63.0": [ "New (#428): the importable SDK is now statically typed -- a PEP 561 `py.typed` " "marker ships in the wheel and the high-traffic facade operations return typed " diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index 06c3ac98..dad83163 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -5,12 +5,45 @@ and ensure consistency across the codebase. """ +from importlib.metadata import PackageNotFoundError, version + import httpx # --- Application identity --- -# Distribution/package name; single source for importlib.metadata lookup and -# the User-Agent product token that signs every Keboola API call. -APP_NAME: str = "keboola-cli" +# Distribution name used for the importlib.metadata version lookup and the +# User-Agent product token that signs every Keboola API call. +# +# Resolved DYNAMICALLY so one codebase runs correctly under BOTH the current +# distribution (`keboola-cli`) and the legacy pre-0.63 name +# (`keboola-agent-cli`). The PyPI rename (#424) ships a migration-bridge wheel +# under the legacy name so already-installed <=0.62 users self-update in place; +# under that distribution `version("keboola-cli")` raises PackageNotFoundError, +# which would break `kbagent version` if APP_NAME were a fixed literal. +# +# NOTE: this is the PyPI/distribution identity ONLY. The on-disk config dir +# (`~/.config/keboola-agent-cli/`) is a SEPARATE fixed literal in +# config_store.py -- it must not move with the distribution name or existing +# users would lose their config location. +APP_NAME_CANDIDATES: tuple[str, ...] = ("keboola-cli", "keboola-agent-cli") + + +def _resolve_app_name() -> str: + """Return the installed distribution name, preferring the current one. + + Falls back to the first (current) candidate when neither is importable -- + an editable/source checkout without built metadata -- so callers always get + a stable product token. + """ + for name in APP_NAME_CANDIDATES: + try: + version(name) + return name + except PackageNotFoundError: + continue + return APP_NAME_CANDIDATES[0] + + +APP_NAME: str = _resolve_app_name() # --- Sentinel for missing metadata keys --- # Distinguishes "key absent" from "value is None/null" in branch metadata lookups. diff --git a/src/keboola_agent_cli/services/version_service.py b/src/keboola_agent_cli/services/version_service.py index 2ee86c9b..0bca7991 100644 --- a/src/keboola_agent_cli/services/version_service.py +++ b/src/keboola_agent_cli/services/version_service.py @@ -821,22 +821,46 @@ def self_update(self, *, include_prerelease: bool = False) -> dict[str, Any]: } @staticmethod - def _compose_update_summary(kbagent_result: dict[str, Any], mcp_result: dict[str, Any]) -> str: - """Build a one-line summary of the two-stage update result.""" + def _summarize_failure_tail(message: str | None) -> str: + """Compress a multi-line failure message to its last non-empty line. + + Subprocess failures embed the whole uv/pip transcript; the actionable + line (e.g. ``error: Executable already exists: kbagent``) is last. We + surface only that tail in the one-line summary -- the full transcript + stays in the result's ``output`` for ``--json`` / ``--verbose``. + """ + lines = [ln.strip() for ln in (message or "").splitlines() if ln.strip()] + return lines[-1] if lines else "update failed" + + @classmethod + def _compose_update_summary( + cls, kbagent_result: dict[str, Any], mcp_result: dict[str, Any] + ) -> str: + """Build a one-line summary of the two-stage update result. + + A non-upgraded stage is only "already up to date" when it explicitly + reports ``up_to_date``. Any other ``updated=False`` means the upgrade + was ATTEMPTED and FAILED -- surface it instead of masking it as + success (the silent-failure bug behind the #424 rename: a failed + self-update was reported as "already up to date"). + """ parts: list[str] = [] if kbagent_result.get("updated"): parts.append( f"kbagent v{kbagent_result.get('current_version')}" f" -> v{kbagent_result.get('latest_version')}" ) - elif kbagent_result.get("current_version") and kbagent_result.get("latest_version"): + elif kbagent_result.get("up_to_date"): parts.append(f"kbagent v{kbagent_result.get('current_version')} (already up to date)") + elif kbagent_result.get("current_version"): + tail = cls._summarize_failure_tail(kbagent_result.get("message")) + parts.append(f"kbagent v{kbagent_result.get('current_version')} update FAILED: {tail}") if mcp_result.get("updated"): current = mcp_result.get("current_version") or "unknown" latest = mcp_result.get("latest_version") or "?" parts.append(f"keboola-mcp-server v{current} -> v{latest}") - elif mcp_result.get("updated") is False and mcp_result.get("current_version"): + elif mcp_result.get("up_to_date"): parts.append( f"keboola-mcp-server v{mcp_result.get('current_version')} (already up to date)" ) @@ -865,6 +889,7 @@ def _update_kbagent(*, include_prerelease: bool = False) -> dict[str, Any]: if up_to_date is True: return { "updated": False, + "up_to_date": True, "current_version": old_version, "latest_version": kbagent_latest, "message": f"kbagent v{old_version} is already up to date.", @@ -943,6 +968,7 @@ def _update_mcp() -> dict[str, Any]: if up_to_date is True: return { "updated": False, + "up_to_date": True, "current_version": local_version, "latest_version": latest_version, "install_method": method, diff --git a/tests/test_http_base.py b/tests/test_http_base.py index 7a7a9523..7f68471b 100644 --- a/tests/test_http_base.py +++ b/tests/test_http_base.py @@ -2,6 +2,7 @@ import platform from typing import SupportsIndex +from unittest.mock import patch import httpx import pytest @@ -643,6 +644,45 @@ def test_base_client_overrides_caller_user_agent(self, httpx_mock) -> None: assert "stale/0.0.1" not in request.headers["User-Agent"] +class TestResolveAppName: + """Dynamic distribution-name resolution (PyPI rename bridge, #424). + + One codebase must run under BOTH `keboola-cli` (current) and the legacy + `keboola-agent-cli` distribution (the migration-bridge wheel), so + `version()` / User-Agent never break depending on how the user installed. + """ + + def _patch_version(self, installed: set[str]): + from importlib.metadata import PackageNotFoundError + + def fake_version(name: str) -> str: + if name in installed: + return "0.63.1" + raise PackageNotFoundError(name) + + return patch("keboola_agent_cli.constants.version", side_effect=fake_version) + + def test_prefers_current_name(self) -> None: + from keboola_agent_cli.constants import _resolve_app_name + + with self._patch_version({"keboola-cli", "keboola-agent-cli"}): + assert _resolve_app_name() == "keboola-cli" + + def test_falls_back_to_legacy_name(self) -> None: + from keboola_agent_cli.constants import _resolve_app_name + + # Installed via the bridge wheel -> only the legacy distribution exists. + with self._patch_version({"keboola-agent-cli"}): + assert _resolve_app_name() == "keboola-agent-cli" + + def test_defaults_to_current_when_neither_installed(self) -> None: + from keboola_agent_cli.constants import APP_NAME_CANDIDATES, _resolve_app_name + + # Editable/source checkout without built metadata -> stable default. + with self._patch_version(set()): + assert _resolve_app_name() == APP_NAME_CANDIDATES[0] == "keboola-cli" + + class TestBaseHttpClientContextManager: """Test context manager protocol on BaseHttpClient.""" diff --git a/tests/test_version_service.py b/tests/test_version_service.py index c590aea0..d4ef0d5f 100644 --- a/tests/test_version_service.py +++ b/tests/test_version_service.py @@ -1235,3 +1235,59 @@ def test_user_facing_command_opts_into_prereleases( assert expected_flag in upgrade_command, ( f"{method} upgrade_command missing {expected_flag}: {upgrade_command!r}" ) + + +class TestComposeUpdateSummary: + """The one-line summary must distinguish up-to-date from a FAILED update. + + Regression for the #424 rename breakage: a self-update that failed (e.g. + `Executable already exists: kbagent`) was rendered as `already up to date`, + so users had no signal the upgrade never happened. + """ + + def test_kbagent_failure_is_surfaced_not_masked(self) -> None: + kbagent = { + "updated": False, + "current_version": "0.62.0", + "latest_version": "0.63.1", + "message": ( + "Update failed: Resolved 46 packages\n" + "error: Executable already exists: kbagent (use `--force` to overwrite)" + ), + } + mcp = {"updated": False, "up_to_date": True, "current_version": "1.66.0"} + + summary = VersionService._compose_update_summary(kbagent, mcp) + + assert "update FAILED" in summary + assert "Executable already exists: kbagent" in summary + # The masking bug: a failure must NOT be reported as up to date. + assert "(already up to date)" not in summary.split("|")[0] + + def test_kbagent_up_to_date_renders_up_to_date(self) -> None: + kbagent = { + "updated": False, + "up_to_date": True, + "current_version": "0.63.1", + "latest_version": "0.63.1", + } + mcp = {"updated": False, "up_to_date": True, "current_version": "1.66.0"} + + summary = VersionService._compose_update_summary(kbagent, mcp) + + assert "kbagent v0.63.1 (already up to date)" in summary + assert "FAILED" not in summary + + def test_kbagent_upgraded_renders_arrow(self) -> None: + kbagent = {"updated": True, "current_version": "0.62.0", "latest_version": "0.63.1"} + mcp = {"updated": False, "up_to_date": True, "current_version": "1.66.0"} + + summary = VersionService._compose_update_summary(kbagent, mcp) + + assert "kbagent v0.62.0 -> v0.63.1" in summary + + def test_failure_tail_is_last_nonempty_line(self) -> None: + msg = "Update failed: line one\n\n error: the real reason \n" + assert VersionService._summarize_failure_tail(msg) == "error: the real reason" + assert VersionService._summarize_failure_tail("") == "update failed" + assert VersionService._summarize_failure_tail(None) == "update failed" diff --git a/uv.lock b/uv.lock index f703f3bf..76f3b48f 100644 --- a/uv.lock +++ b/uv.lock @@ -580,7 +580,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.63.0" +version = "0.63.1" source = { editable = "." } dependencies = [ { name = "croniter" },