From fa69434fc4cfb0f5e814ef24a0f6933cc562c05b Mon Sep 17 00:00:00 2001 From: soustruh Date: Sat, 10 Oct 2026 03:30:32 +0200 Subject: [PATCH 1/3] fix(update): show why a startup update failed, and how to move an install from the old package name (#771) --- README.md | 23 +++ .../skills/kbagent/references/gotchas.md | 11 ++ src/keboola_agent_cli/auto_update.py | 60 +++++--- src/keboola_agent_cli/constants.py | 1 + .../services/version_service.py | 39 ++++- tests/test_auto_update.py | 134 +++++++++++++++--- tests/test_version_service.py | 41 ++++++ 7 files changed, 275 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 1c7370eff..9c21e5d67 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,29 @@ A fresh Windows install has no real Python either: `python` resolves to a Micros Auto-updates kbagent on every launch; the self-update prefers the prebuilt wheel when available. Run `kbagent changelog` to see what changed. (Since 0.85.0 kbagent updates itself only -- if you also run `keboola-mcp-server` in Claude Desktop / Cursor, refresh it yourself with `uv tool install --upgrade --prerelease=allow keboola-mcp-server`.) +### Installed under the old package name + +The package was named `keboola-agent-cli` up to 0.62.0. It is named `keboola-cli` from 0.63.0. Both packages install the `kbagent` executable. uv treats an update to `keboola-cli` as a new tool and does not overwrite the executable that the old tool owns. + +To check which name you have, run `uv tool list`. An old install is listed as `keboola-agent-cli`. + +- kbagent 0.59.x and older cannot update itself. Every command prints `Auto-update failed; continuing with current version.` and the version does not change. Without the `[server]` extras, the uv error behind it is ``error: Executable already exists: kbagent (use `--force` to overwrite)``. No release can change this, because the update code runs from the old install. +- kbagent 0.60.0 to 0.62.x updates through a compatibility wheel and stays on the old name. +- kbagent 0.63.1 and newer installs a second tool, `keboola-cli`, when it updates. The new tool takes over the `kbagent` executable and the old `keboola-agent-cli` entry stays in `uv tool list`. + +To move to `keboola-cli`, run these two commands in this order, in a shell where no kbagent is running: + +```bash +uv tool uninstall keboola-agent-cli +curl -LsSf https://raw.githubusercontent.com/keboola/cli/main/install.sh | sh +``` + +On Windows, run the PowerShell install from the Windows section above as the second step. Your configuration is not touched: the configuration folder keeps the name `keboola-agent-cli` on purpose. + +If `uv tool list` shows `keboola-cli` next to `keboola-agent-cli`, the update already worked. kbagent runs from `keboola-cli` and the old entry does no harm. Do not remove the old entry on its own: `uv tool uninstall keboola-agent-cli` also deletes the `kbagent` executable that `keboola-cli` uses, and the shell then reports `command not found`. To restore the executable, run `uv tool install --force --reinstall "keboola-cli[server] @ https://github.com/keboola/cli/releases/download/v/keboola_cli--py3-none-any.whl"` with the latest version number from the [releases page](https://github.com/keboola/cli/releases/latest). + +A failed startup update prints the last line of the uv output as `Cause:` (since vNEXT). On an install under the old name it also prints the two commands above. + ## Web UI (optional) Want a browser dashboard? One command: diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index c9149ba70..ba3d07f73 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -6057,3 +6057,14 @@ appeared in human mode on a terminal. On older versions `--progress` does not exist and the command fails with a usage error (exit 2) -- check `kbagent version` before passing it. +## An install under the old package name `keboola-agent-cli` does not update to `keboola-cli` + +*(since vNEXT, #771)* + +The package was named `keboola-agent-cli` up to 0.62.0 and is named `keboola-cli` from 0.63.0. Both install the `kbagent` executable, so uv treats an update to `keboola-cli` as a new tool. If a user reports a kbagent that never updates, run `uv tool list` and look for a `keboola-agent-cli` entry. + +- **kbagent 0.59.x and older cannot update itself.** Every command prints `Auto-update failed; continuing with current version.` with no cause. Without the `[server]` extras the uv error is ``error: Executable already exists: kbagent (use `--force` to overwrite)``. The update code runs from the old install, so no release changes this. +- **Do not tell the user to run `kbagent update` or the `upgrade_command` from that old `kbagent version --json`.** Both run the same failing command. +- **Move the user with two commands, in this order:** `uv tool uninstall keboola-agent-cli`, then the install one-liner from the README. The configuration folder keeps the name `keboola-agent-cli` and is not touched. +- **The order is load-bearing.** kbagent 0.63.1 and newer updates by installing a second tool, `keboola-cli`, that takes over the `kbagent` executable. `uv tool uninstall keboola-agent-cli` after that also deletes the executable, and the shell reports `command not found`. Reinstall with `uv tool install --force --reinstall "keboola-cli[server] @ "`. +- **Failure banner.** A failed startup update now prints a `Cause:` line with the last line of the uv output, then `Recover with: `, on stderr. Before, it printed only `Auto-update failed; continuing with current version.` and the recovery command. On an install that runs under the old name, the banner replaces `Recover with:` by the two commands above. `kbagent update` is unchanged: its `update FAILED:` summary already ends with the last uv line. diff --git a/src/keboola_agent_cli/auto_update.py b/src/keboola_agent_cli/auto_update.py index e608ef9a9..06a398b4d 100644 --- a/src/keboola_agent_cli/auto_update.py +++ b/src/keboola_agent_cli/auto_update.py @@ -15,6 +15,7 @@ import shutil import sys import time +from dataclasses import dataclass from importlib.metadata import distribution from pathlib import Path @@ -37,8 +38,10 @@ build_hardlink_retry_command, build_kbagent_upgrade_command, get_update_timeout, + legacy_install_advice, prepare_kbagent_update_plan, resolve_kbagent_wheel_url, + summarize_install_failure, ) from .update_runner import ( DeferredUpdateReport, @@ -68,6 +71,14 @@ class UpdateOutcome(enum.Enum): FAILED = "failed" +@dataclass(frozen=True) +class UpdateResult: + """An :class:`UpdateOutcome` plus what the installer printed when it failed (issue #771).""" + + outcome: UpdateOutcome + output: str = "" + + # Process-level sentinel for the auto-update flow. # # Bug D fix from issue #263: ``kbagent repl`` re-enters the entire CLI @@ -273,9 +284,7 @@ def _should_skip() -> bool: return _should_skip_kbagent_stage() or _should_skip_all() -def _perform_update( - latest_version: str, *, command: tuple[str, ...] | None = None -) -> UpdateOutcome: +def _perform_update(latest_version: str, *, command: tuple[str, ...] | None = None) -> UpdateResult: """Download and install the latest version. Delegates to :func:`build_kbagent_upgrade_command` so this path stays @@ -290,10 +299,10 @@ def _perform_update( latest_version: The version being updated to (for logging). Returns: - :class:`UpdateOutcome`: ``SUCCESS`` on a clean install, ``TIMEOUT`` when - the install outran :func:`get_update_timeout` -- it is still running and - will finish on its own, so the next launch picks it up -- and ``FAILED`` - otherwise. + :class:`UpdateResult` whose ``outcome`` is ``SUCCESS`` on a clean install, + ``TIMEOUT`` when the install outran :func:`get_update_timeout` -- it is + still running and will finish on its own, so the next launch picks it up -- + and ``FAILED`` otherwise, with the installer's output in ``output``. """ # ``command`` is supplied by the startup planner. Keep the fallback only # for direct legacy callers; it must never be used after another stage has @@ -304,17 +313,38 @@ def _perform_update( else: cmd = list(command) if cmd is None: - return UpdateOutcome.FAILED + return UpdateResult(UpdateOutcome.FAILED) # Deliberately does NOT kill the installer when the deadline passes -- see # ``update_runner.run_install``. A terminated uv leaves the same # half-removed venv a Windows file lock does (issue #528). run = run_install(tuple(cmd), timeout=get_update_timeout()) if run.status is InstallStatus.SUCCEEDED: - return UpdateOutcome.SUCCESS + return UpdateResult(UpdateOutcome.SUCCESS) if run.status is InstallStatus.STILL_RUNNING: - return UpdateOutcome.TIMEOUT - return UpdateOutcome.FAILED + return UpdateResult(UpdateOutcome.TIMEOUT) + return UpdateResult(UpdateOutcome.FAILED, run.output) + + +def _format_update_failure(output: str, recovery_command: str | None) -> str: + """Render the startup banner for a failed install (issue #771). + + Names the line the installer ended with. Without it the banner cannot tell + a network error from a permanent one such as uv's ``Executable already + exists`` on an install made under the old package name. That install gets + the steps that move it to ``keboola-cli``; every other install gets the + recovery command. + """ + lines = ["Auto-update failed; continuing with current version."] + cause = summarize_install_failure(output) + if cause: + lines.append(f"Cause: {cause}") + advice = legacy_install_advice(recovery_command) + if advice: + lines.append(advice) + elif recovery_command: + lines.append(f"Recover with: {recovery_command}") + return "\n".join(lines) + "\n" def _re_exec() -> None: @@ -594,9 +624,10 @@ def maybe_auto_update() -> None: return sys.stderr.write(f"Updating kbagent v{__version__} -> v{kbagent_plan.latest_version}...\n") - outcome = _perform_update( + result = _perform_update( kbagent_plan.latest_version or __version__, command=kbagent_plan.command ) + outcome = result.outcome if outcome is UpdateOutcome.SUCCESS: sys.stderr.write(f"Updated to v{kbagent_plan.latest_version}. Re-launching...\n") os.environ[ENV_UPDATED_FROM] = __version__ @@ -611,10 +642,7 @@ def maybe_auto_update() -> None: "background and applies on the next launch.\n" ) else: - sys.stderr.write( - "Auto-update failed; continuing with current version. Recover with: " - f"{kbagent_plan.recovery_command}\n" - ) + sys.stderr.write(_format_update_failure(result.output, kbagent_plan.recovery_command)) except Exception: # Blanket catch: auto-update must NEVER crash the CLI. logger.debug("Auto-update check failed", exc_info=True) diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index 99b8bfc04..a1105ee78 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -25,6 +25,7 @@ # 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") +LEGACY_APP_NAME: str = APP_NAME_CANDIDATES[1] def _resolve_app_name() -> str: diff --git a/src/keboola_agent_cli/services/version_service.py b/src/keboola_agent_cli/services/version_service.py index 326fb76da..cc714b1c1 100644 --- a/src/keboola_agent_cli/services/version_service.py +++ b/src/keboola_agent_cli/services/version_service.py @@ -19,9 +19,11 @@ from .. import __version__ from ..constants import ( + APP_NAME, ENV_UPDATE_TIMEOUT, KBAGENT_GITHUB_REPO, KBAGENT_INSTALL_SOURCE, + LEGACY_APP_NAME, UPDATE_TIMEOUT_SECONDS, VERSION_CHECK_TIMEOUT, ) @@ -312,6 +314,40 @@ def _recovery_command(command: tuple[str, ...] | None, target_version: str | Non ) +def summarize_install_failure(output: str | None) -> str: + """Return the last non-empty line of an installer transcript, or "". + + uv and pip print their actionable line last (for example + ``error: Executable already exists: kbagent (use `--force` to overwrite)``), + so this is the cause to show in a one-line failure message. + """ + lines = [line.strip() for line in (output or "").splitlines() if line.strip()] + return lines[-1] if lines else "" + + +def legacy_install_advice(recovery_command: str | None) -> str | None: + """Return the steps that move a legacy-named install to ``keboola-cli``. + + Returns ``None`` for every other install, so only an install made under + the pre-0.63 distribution name sees it (issue #771). The order is + load-bearing: ``uv tool uninstall`` removes the ``kbagent`` executable even + when a newer ``keboola-cli`` entry has taken it over, so the install must + come second. + + Args: + recovery_command: The install command that recreates the tool + environment under the current name. + """ + if APP_NAME != LEGACY_APP_NAME or recovery_command is None: + return None + return ( + f"This install uses the old package name {LEGACY_APP_NAME}. " + "To move it to keboola-cli, run these two commands in this order:\n" + f" uv tool uninstall {LEGACY_APP_NAME}\n" + f" {recovery_command}" + ) + + def _fetch_kbagent_latest_version( timeout: float = VERSION_CHECK_TIMEOUT, *, include_prerelease: bool = False ) -> str | None: @@ -634,8 +670,7 @@ def _summarize_failure_tail(message: str | None) -> str: 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" + return summarize_install_failure(message) or "update failed" @classmethod def _compose_update_summary(cls, kbagent_result: dict[str, Any]) -> str: diff --git a/tests/test_auto_update.py b/tests/test_auto_update.py index 92e742148..337d8c329 100644 --- a/tests/test_auto_update.py +++ b/tests/test_auto_update.py @@ -9,8 +9,10 @@ import pytest import keboola_agent_cli.auto_update as auto_update_module +import keboola_agent_cli.services.version_service as version_service_module from keboola_agent_cli.auto_update import ( UpdateOutcome, + UpdateResult, _get_cache_path, _is_cache_fresh, _is_dev_install, @@ -285,6 +287,16 @@ def test_cache_fresh_with_bad_data(self): # --------------------------------------------------------------------------- # _perform_update # --------------------------------------------------------------------------- +# What uv prints when the `kbagent` executable belongs to another tool entry +# (issue #771). Captured from uv 0.12; a fake install run returns it, so no test +# ever starts a real uv. +UV_EXECUTABLE_CONFLICT = ( + "Resolved 1 package in 3ms\n" + " + keboola-cli==0.96.0\n" + "error: Executable already exists: kbagent (use `--force` to overwrite)\n" +) + + def _finished_install(status: InstallStatus, exit_code: int | None = 0) -> InstallRun: """An :class:`InstallRun` stand-in for a mocked installer execution.""" return InstallRun(status=status, exit_code=exit_code, output="", log_path=Path("update.log")) @@ -304,7 +316,7 @@ class TestPerformUpdate: def test_update_with_uv_success(self, mock_install, mock_which): mock_which.return_value = "/usr/local/bin/uv" mock_install.return_value = _finished_install(InstallStatus.SUCCEEDED) - assert _perform_update("2.0.0") is UpdateOutcome.SUCCESS + assert _perform_update("2.0.0").outcome is UpdateOutcome.SUCCESS assert "uv" in mock_install.call_args.args[0][0] @patch("shutil.which") @@ -312,7 +324,22 @@ def test_update_with_uv_success(self, mock_install, mock_which): def test_update_with_uv_failure(self, mock_install, mock_which): mock_which.return_value = "/usr/local/bin/uv" mock_install.return_value = _finished_install(InstallStatus.FAILED, exit_code=1) - assert _perform_update("2.0.0") is UpdateOutcome.FAILED + assert _perform_update("2.0.0").outcome is UpdateOutcome.FAILED + + @patch("shutil.which") + @patch("keboola_agent_cli.auto_update.run_install") + def test_failure_carries_the_installer_output(self, mock_install, mock_which): + """The output is what the startup banner names as the cause (issue #771).""" + mock_which.return_value = "/usr/local/bin/uv" + mock_install.return_value = InstallRun( + status=InstallStatus.FAILED, + exit_code=2, + output=UV_EXECUTABLE_CONFLICT, + log_path=Path("update.log"), + ) + result = _perform_update("2.0.0") + assert result.outcome is UpdateOutcome.FAILED + assert result.output == UV_EXECUTABLE_CONFLICT @patch("shutil.which") @patch("keboola_agent_cli.auto_update.run_install") @@ -322,13 +349,13 @@ def test_update_pip_fallback(self, mock_install, mock_which): None if cmd == "uv" else "/usr/bin/pip" if cmd == "pip" else None ) mock_install.return_value = _finished_install(InstallStatus.SUCCEEDED) - assert _perform_update("2.0.0") is UpdateOutcome.SUCCESS + assert _perform_update("2.0.0").outcome is UpdateOutcome.SUCCESS assert "pip" in mock_install.call_args.args[0][0] @patch("shutil.which") def test_update_no_tools(self, mock_which): mock_which.return_value = None - assert _perform_update("2.0.0") is UpdateOutcome.FAILED + assert _perform_update("2.0.0").outcome is UpdateOutcome.FAILED @patch("shutil.which") @patch("keboola_agent_cli.auto_update.run_install") @@ -340,7 +367,7 @@ def test_still_running_maps_to_timeout(self, mock_install, mock_which): """ mock_which.return_value = "/usr/local/bin/uv" mock_install.return_value = _finished_install(InstallStatus.STILL_RUNNING, exit_code=None) - assert _perform_update("2.0.0") is UpdateOutcome.TIMEOUT + assert _perform_update("2.0.0").outcome is UpdateOutcome.TIMEOUT @patch( "keboola_agent_cli.services.version_service.has_server_extras", @@ -361,7 +388,7 @@ def test_update_preserves_server_extras(self, mock_install, mock_which, mock_has ``--force`` when ``fastapi`` is importable. """ mock_install.return_value = _finished_install(InstallStatus.SUCCEEDED) - assert _perform_update("2.0.0") is UpdateOutcome.SUCCESS + assert _perform_update("2.0.0").outcome is UpdateOutcome.SUCCESS argv = mock_install.call_args.args[0] # The extras live in the primary PEP 508 requirement so the complete # environment is resolved in one forced reinstall. @@ -381,7 +408,7 @@ def test_update_without_server_extras_uses_upgrade( ): """No-extras installs are also a full forced reinstall.""" mock_install.return_value = _finished_install(InstallStatus.SUCCEEDED) - assert _perform_update("2.0.0") is UpdateOutcome.SUCCESS + assert _perform_update("2.0.0").outcome is UpdateOutcome.SUCCESS argv = mock_install.call_args.args[0] assert "--force" in argv assert "--reinstall" in argv @@ -405,7 +432,7 @@ def test_installs_wheel_when_asset_present( mock_head.return_value = MagicMock(status_code=200) mock_install.return_value = _finished_install(InstallStatus.SUCCEEDED) - assert _perform_update("2.0.0") is UpdateOutcome.SUCCESS + assert _perform_update("2.0.0").outcome is UpdateOutcome.SUCCESS argv = mock_install.call_args.args[0] # PEP 508 direct ref to the versioned wheel, --force, and no git+ source. @@ -421,7 +448,7 @@ def test_falls_back_to_git_when_no_asset(self, mock_install, mock_which, mock_he mock_head.return_value = MagicMock(status_code=404) mock_install.return_value = _finished_install(InstallStatus.SUCCEEDED) - assert _perform_update("2.0.0") is UpdateOutcome.SUCCESS + assert _perform_update("2.0.0").outcome is UpdateOutcome.SUCCESS argv = mock_install.call_args.args[0] assert any("git+" in part for part in argv) @@ -550,7 +577,10 @@ def test_up_to_date_no_update( @patch("keboola_agent_cli.auto_update._fetch_kbagent_latest_version", return_value="2.0.0") @patch("keboola_agent_cli.auto_update._write_cache") @patch("keboola_agent_cli.auto_update._is_up_to_date", return_value=False) - @patch("keboola_agent_cli.auto_update._perform_update", return_value=UpdateOutcome.SUCCESS) + @patch( + "keboola_agent_cli.auto_update._perform_update", + return_value=UpdateResult(UpdateOutcome.SUCCESS), + ) @patch("keboola_agent_cli.auto_update._re_exec") @patch("keboola_agent_cli.auto_update.__version__", "1.0.0") def test_newer_available_updates_and_reexec( @@ -572,7 +602,10 @@ def test_newer_available_updates_and_reexec( @patch("keboola_agent_cli.auto_update._fetch_kbagent_latest_version", return_value="2.0.0") @patch("keboola_agent_cli.auto_update._write_cache") @patch("keboola_agent_cli.auto_update._is_up_to_date", return_value=False) - @patch("keboola_agent_cli.auto_update._perform_update", return_value=UpdateOutcome.FAILED) + @patch( + "keboola_agent_cli.auto_update._perform_update", + return_value=UpdateResult(UpdateOutcome.FAILED), + ) @patch("keboola_agent_cli.auto_update._re_exec") @patch("keboola_agent_cli.auto_update.__version__", "1.0.0") def test_update_failure_continues( @@ -594,7 +627,7 @@ def test_update_failure_continues( @patch("keboola_agent_cli.auto_update._is_up_to_date", return_value=False) @patch( "keboola_agent_cli.auto_update._perform_update", - return_value=UpdateOutcome.TIMEOUT, + return_value=UpdateResult(UpdateOutcome.TIMEOUT), ) @patch("keboola_agent_cli.auto_update._re_exec") @patch("keboola_agent_cli.auto_update._write_cache") @@ -917,12 +950,12 @@ def write_cache(**kwargs: object) -> None: assert not mutated events.append("cache") - def perform(version: str, *, command: tuple[str, ...]) -> UpdateOutcome: + def perform(version: str, *, command: tuple[str, ...]) -> UpdateResult: nonlocal mutated assert events[-1] == "cache" mutated = True events.append("kbagent") - return UpdateOutcome.SUCCESS + return UpdateResult(UpdateOutcome.SUCCESS) def reexec() -> None: assert mutated @@ -952,6 +985,72 @@ def reexec() -> None: assert auto_update_module._AUTO_UPDATE_RAN is True +class TestStartupFailureBanner: + """A failed startup install names the cause the installer reported (issue #771). + + The installer is faked at `run_install`, so the whole path from the fake uv + transcript to stderr runs without starting uv. + """ + + RECOVERY = "uv tool install --force --reinstall 'keboola-cli @ https://example.test/k.whl'" + + @pytest.fixture + def run_failed_update(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture): + def run(*, app_name: str, install_output: str) -> str: + plan = KbagentUpdatePlan("1.0.0", "2.0.0", False, ("uv", "tool"), self.RECOVERY) + monkeypatch.setattr(auto_update_module, "_AUTO_UPDATE_RAN", False) + monkeypatch.setattr(auto_update_module, "_should_skip_all", lambda: False) + monkeypatch.setattr(auto_update_module, "_should_skip_kbagent_stage", lambda: False) + monkeypatch.setattr(auto_update_module, "_read_cache", lambda: None) + monkeypatch.setattr(auto_update_module, "_write_cache", lambda **_: None) + monkeypatch.setattr( + auto_update_module, "_fetch_kbagent_latest_version", lambda **_: "2.0.0" + ) + monkeypatch.setattr(auto_update_module, "_prepare_auto_kbagent_plan", lambda _: plan) + monkeypatch.setattr(auto_update_module, "__version__", "1.0.0") + monkeypatch.setattr(version_service_module, "APP_NAME", app_name) + monkeypatch.setattr( + auto_update_module, + "run_install", + lambda *_, **__: InstallRun( + InstallStatus.FAILED, 2, install_output, Path("update.log") + ), + ) + re_exec = MagicMock() + monkeypatch.setattr(auto_update_module, "_re_exec", re_exec) + capsys.readouterr() + maybe_auto_update() + re_exec.assert_not_called() + return capsys.readouterr().err + + return run + + def test_banner_names_the_line_uv_ended_with(self, run_failed_update): + err = run_failed_update(app_name="keboola-cli", install_output=UV_EXECUTABLE_CONFLICT) + + assert "Auto-update failed; continuing with current version." in err + assert "Cause: error: Executable already exists: kbagent" in err + assert f"Recover with: {self.RECOVERY}" in err + # Only a legacy-named install is told to uninstall anything. + assert "uv tool uninstall" not in err + + def test_legacy_install_gets_the_move_steps_in_order(self, run_failed_update): + err = run_failed_update(app_name="keboola-agent-cli", install_output=UV_EXECUTABLE_CONFLICT) + + assert "Cause: error: Executable already exists: kbagent" in err + uninstall = err.index("uv tool uninstall keboola-agent-cli") + install = err.index(self.RECOVERY) + # The uninstall also deletes the shared `kbagent` executable, so it must + # run first. + assert uninstall < install + + def test_no_installer_output_prints_no_cause_line(self, run_failed_update): + err = run_failed_update(app_name="keboola-cli", install_output="") + + assert "Cause:" not in err + assert f"Recover with: {self.RECOVERY}" in err + + # --------------------------------------------------------------------------- # Deferred (out-of-process) startup update -- issue #528 # --------------------------------------------------------------------------- @@ -1020,7 +1119,10 @@ def test_unschedulable_prints_the_command_and_installs_nothing( @patch("keboola_agent_cli.auto_update.should_defer", return_value=False) @patch("keboola_agent_cli.auto_update.request_deferred_update") - @patch("keboola_agent_cli.auto_update._perform_update", return_value=UpdateOutcome.SUCCESS) + @patch( + "keboola_agent_cli.auto_update._perform_update", + return_value=UpdateResult(UpdateOutcome.SUCCESS), + ) @patch("keboola_agent_cli.auto_update._re_exec") def test_posix_keeps_the_inline_install_and_re_exec( self, mock_reexec, mock_perform, mock_request, mock_defer @@ -1246,7 +1348,7 @@ def test_non_frozen_install_is_unaffected(self, capsys): patch("keboola_agent_cli.auto_update._prepare_auto_kbagent_plan", return_value=plan), patch( "keboola_agent_cli.auto_update._perform_update", - return_value=UpdateOutcome.SUCCESS, + return_value=UpdateResult(UpdateOutcome.SUCCESS), ) as mock_perform, patch("keboola_agent_cli.auto_update._re_exec") as mock_reexec, ): diff --git a/tests/test_version_service.py b/tests/test_version_service.py index 7ecbe2129..f37fc4312 100644 --- a/tests/test_version_service.py +++ b/tests/test_version_service.py @@ -17,8 +17,10 @@ build_hardlink_retry_command, build_kbagent_upgrade_command, get_update_timeout, + legacy_install_advice, prepare_kbagent_update_plan, resolve_kbagent_wheel_url, + summarize_install_failure, ) from keboola_agent_cli.update_runner import DeferredUpdateRequest, InstallRun, InstallStatus @@ -913,6 +915,45 @@ def test_failure_tail_is_last_nonempty_line(self) -> None: assert VersionService._summarize_failure_tail(None) == "update failed" +class TestSummarizeInstallFailure: + """The cause shown in a failure message is the installer's last line (#771).""" + + def test_returns_the_last_non_empty_line(self) -> None: + output = ( + "Resolved 1 package in 3ms\n" + "error: Executable already exists: kbagent (use `--force` to overwrite)\n\n" + ) + assert summarize_install_failure(output) == ( + "error: Executable already exists: kbagent (use `--force` to overwrite)" + ) + + @pytest.mark.parametrize("output", [None, "", " \n\n"]) + def test_returns_empty_string_without_output(self, output: str | None) -> None: + assert summarize_install_failure(output) == "" + + +class TestLegacyInstallAdvice: + """Only an install under the old package name is told to move (#771).""" + + RECOVERY = "uv tool install --force --reinstall 'keboola-cli @ https://example.test/k.whl'" + + def test_legacy_install_gets_uninstall_before_install(self) -> None: + with patch(f"{VS}.APP_NAME", "keboola-agent-cli"): + advice = legacy_install_advice(self.RECOVERY) + + assert advice is not None + assert advice.index("uv tool uninstall keboola-agent-cli") < advice.index(self.RECOVERY) + + def test_current_install_gets_no_advice(self) -> None: + with patch(f"{VS}.APP_NAME", "keboola-cli"): + assert legacy_install_advice(self.RECOVERY) is None + + def test_no_recovery_command_means_no_advice(self) -> None: + # An uninstall without the install after it would delete `kbagent`. + with patch(f"{VS}.APP_NAME", "keboola-agent-cli"): + assert legacy_install_advice(None) is None + + class TestSummaryDistinguishesNotYetFromFailed: """ "Not updated" has three meanings; only one of them is a failure (#528).""" From 8a53a8536bbd72d19248e8a35c34cb91cb9b4ae8 Mon Sep 17 00:00:00 2001 From: soustruh Date: Sat, 10 Oct 2026 03:33:11 +0200 Subject: [PATCH 2/3] fix(update): give the legacy move steps only for the executable conflict --- README.md | 2 +- plugins/kbagent/skills/kbagent/references/gotchas.md | 2 +- src/keboola_agent_cli/auto_update.py | 12 ++++++++---- tests/test_auto_update.py | 11 +++++++++++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9c21e5d67..021be65d2 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ On Windows, run the PowerShell install from the Windows section above as the sec If `uv tool list` shows `keboola-cli` next to `keboola-agent-cli`, the update already worked. kbagent runs from `keboola-cli` and the old entry does no harm. Do not remove the old entry on its own: `uv tool uninstall keboola-agent-cli` also deletes the `kbagent` executable that `keboola-cli` uses, and the shell then reports `command not found`. To restore the executable, run `uv tool install --force --reinstall "keboola-cli[server] @ https://github.com/keboola/cli/releases/download/v/keboola_cli--py3-none-any.whl"` with the latest version number from the [releases page](https://github.com/keboola/cli/releases/latest). -A failed startup update prints the last line of the uv output as `Cause:` (since vNEXT). On an install under the old name it also prints the two commands above. +A failed startup update prints the last line of the uv output as `Cause:` (since vNEXT). When uv stops with `Executable already exists` on an install under the old name, it prints the two commands above instead of the recovery command. ## Web UI (optional) diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index ba3d07f73..7190a0909 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -6067,4 +6067,4 @@ The package was named `keboola-agent-cli` up to 0.62.0 and is named `keboola-cli - **Do not tell the user to run `kbagent update` or the `upgrade_command` from that old `kbagent version --json`.** Both run the same failing command. - **Move the user with two commands, in this order:** `uv tool uninstall keboola-agent-cli`, then the install one-liner from the README. The configuration folder keeps the name `keboola-agent-cli` and is not touched. - **The order is load-bearing.** kbagent 0.63.1 and newer updates by installing a second tool, `keboola-cli`, that takes over the `kbagent` executable. `uv tool uninstall keboola-agent-cli` after that also deletes the executable, and the shell reports `command not found`. Reinstall with `uv tool install --force --reinstall "keboola-cli[server] @ "`. -- **Failure banner.** A failed startup update now prints a `Cause:` line with the last line of the uv output, then `Recover with: `, on stderr. Before, it printed only `Auto-update failed; continuing with current version.` and the recovery command. On an install that runs under the old name, the banner replaces `Recover with:` by the two commands above. `kbagent update` is unchanged: its `update FAILED:` summary already ends with the last uv line. +- **Failure banner.** A failed startup update now prints a `Cause:` line with the last line of the uv output, then `Recover with: `, on stderr. Before, it printed only `Auto-update failed; continuing with current version.` and the recovery command. When uv stops with `Executable already exists` on an install that runs under the old name, the banner replaces `Recover with:` by the two commands above. Any other failure on such an install still gets `Recover with:`. `kbagent update` is unchanged: its `update FAILED:` summary already ends with the last uv line. diff --git a/src/keboola_agent_cli/auto_update.py b/src/keboola_agent_cli/auto_update.py index 06a398b4d..b412883fc 100644 --- a/src/keboola_agent_cli/auto_update.py +++ b/src/keboola_agent_cli/auto_update.py @@ -326,20 +326,24 @@ def _perform_update(latest_version: str, *, command: tuple[str, ...] | None = No return UpdateResult(UpdateOutcome.FAILED, run.output) +# uv's refusal when another tool entry owns the ``kbagent`` executable. +_EXECUTABLE_CONFLICT = "Executable already exists" + + def _format_update_failure(output: str, recovery_command: str | None) -> str: """Render the startup banner for a failed install (issue #771). Names the line the installer ended with. Without it the banner cannot tell a network error from a permanent one such as uv's ``Executable already - exists`` on an install made under the old package name. That install gets - the steps that move it to ``keboola-cli``; every other install gets the - recovery command. + exists`` on an install made under the old package name. Only that conflict + gets the steps that move the install to ``keboola-cli``; every other + failure gets the recovery command. """ lines = ["Auto-update failed; continuing with current version."] cause = summarize_install_failure(output) if cause: lines.append(f"Cause: {cause}") - advice = legacy_install_advice(recovery_command) + advice = legacy_install_advice(recovery_command) if _EXECUTABLE_CONFLICT in output else None if advice: lines.append(advice) elif recovery_command: diff --git a/tests/test_auto_update.py b/tests/test_auto_update.py index 337d8c329..3cdfe4fea 100644 --- a/tests/test_auto_update.py +++ b/tests/test_auto_update.py @@ -1044,6 +1044,17 @@ def test_legacy_install_gets_the_move_steps_in_order(self, run_failed_update): # run first. assert uninstall < install + def test_legacy_install_with_another_failure_gets_the_recovery_command(self, run_failed_update): + # A network error on a legacy-named install is not the executable + # conflict, so the uninstall steps would not help. + err = run_failed_update( + app_name="keboola-agent-cli", + install_output="error: Failed to fetch: `https://example.test/k.whl`\n", + ) + + assert "uv tool uninstall" not in err + assert f"Recover with: {self.RECOVERY}" in err + def test_no_installer_output_prints_no_cause_line(self, run_failed_update): err = run_failed_update(app_name="keboola-cli", install_output="") From 08b894dbed818a740d558e0147ca0506812486ba Mon Sep 17 00:00:00 2001 From: soustruh Date: Sat, 10 Oct 2026 08:58:29 +0200 Subject: [PATCH 3/3] fix(update): drop the legacy move advice from the startup banner, the forced reinstall never hits the conflict --- README.md | 2 +- .../skills/kbagent/references/gotchas.md | 2 +- src/keboola_agent_cli/auto_update.py | 17 +----- src/keboola_agent_cli/constants.py | 1 - .../services/version_service.py | 25 -------- tests/test_auto_update.py | 59 +++++++------------ tests/test_version_service.py | 23 -------- 7 files changed, 25 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 021be65d2..10ec7d7ec 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ On Windows, run the PowerShell install from the Windows section above as the sec If `uv tool list` shows `keboola-cli` next to `keboola-agent-cli`, the update already worked. kbagent runs from `keboola-cli` and the old entry does no harm. Do not remove the old entry on its own: `uv tool uninstall keboola-agent-cli` also deletes the `kbagent` executable that `keboola-cli` uses, and the shell then reports `command not found`. To restore the executable, run `uv tool install --force --reinstall "keboola-cli[server] @ https://github.com/keboola/cli/releases/download/v/keboola_cli--py3-none-any.whl"` with the latest version number from the [releases page](https://github.com/keboola/cli/releases/latest). -A failed startup update prints the last line of the uv output as `Cause:` (since vNEXT). When uv stops with `Executable already exists` on an install under the old name, it prints the two commands above instead of the recovery command. +A failed startup update prints the last line of the uv output as `Cause:` (since vNEXT), then the recovery command. ## Web UI (optional) diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 7190a0909..be2cb8476 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -6067,4 +6067,4 @@ The package was named `keboola-agent-cli` up to 0.62.0 and is named `keboola-cli - **Do not tell the user to run `kbagent update` or the `upgrade_command` from that old `kbagent version --json`.** Both run the same failing command. - **Move the user with two commands, in this order:** `uv tool uninstall keboola-agent-cli`, then the install one-liner from the README. The configuration folder keeps the name `keboola-agent-cli` and is not touched. - **The order is load-bearing.** kbagent 0.63.1 and newer updates by installing a second tool, `keboola-cli`, that takes over the `kbagent` executable. `uv tool uninstall keboola-agent-cli` after that also deletes the executable, and the shell reports `command not found`. Reinstall with `uv tool install --force --reinstall "keboola-cli[server] @ "`. -- **Failure banner.** A failed startup update now prints a `Cause:` line with the last line of the uv output, then `Recover with: `, on stderr. Before, it printed only `Auto-update failed; continuing with current version.` and the recovery command. When uv stops with `Executable already exists` on an install that runs under the old name, the banner replaces `Recover with:` by the two commands above. Any other failure on such an install still gets `Recover with:`. `kbagent update` is unchanged: its `update FAILED:` summary already ends with the last uv line. +- **Failure banner.** A failed startup update now prints a `Cause:` line with the last line of the uv output, then `Recover with: `, on stderr. Before, it printed only `Auto-update failed; continuing with current version.` and the recovery command. `kbagent update` is unchanged: its `update FAILED:` summary already ends with the last uv line. diff --git a/src/keboola_agent_cli/auto_update.py b/src/keboola_agent_cli/auto_update.py index b412883fc..b939c490d 100644 --- a/src/keboola_agent_cli/auto_update.py +++ b/src/keboola_agent_cli/auto_update.py @@ -38,7 +38,6 @@ build_hardlink_retry_command, build_kbagent_upgrade_command, get_update_timeout, - legacy_install_advice, prepare_kbagent_update_plan, resolve_kbagent_wheel_url, summarize_install_failure, @@ -326,27 +325,17 @@ def _perform_update(latest_version: str, *, command: tuple[str, ...] | None = No return UpdateResult(UpdateOutcome.FAILED, run.output) -# uv's refusal when another tool entry owns the ``kbagent`` executable. -_EXECUTABLE_CONFLICT = "Executable already exists" - - def _format_update_failure(output: str, recovery_command: str | None) -> str: """Render the startup banner for a failed install (issue #771). - Names the line the installer ended with. Without it the banner cannot tell - a network error from a permanent one such as uv's ``Executable already - exists`` on an install made under the old package name. Only that conflict - gets the steps that move the install to ``keboola-cli``; every other - failure gets the recovery command. + Names the line the installer ended with, so the user can tell a network + error from a permanent one. """ lines = ["Auto-update failed; continuing with current version."] cause = summarize_install_failure(output) if cause: lines.append(f"Cause: {cause}") - advice = legacy_install_advice(recovery_command) if _EXECUTABLE_CONFLICT in output else None - if advice: - lines.append(advice) - elif recovery_command: + if recovery_command: lines.append(f"Recover with: {recovery_command}") return "\n".join(lines) + "\n" diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index a1105ee78..99b8bfc04 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -25,7 +25,6 @@ # 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") -LEGACY_APP_NAME: str = APP_NAME_CANDIDATES[1] def _resolve_app_name() -> str: diff --git a/src/keboola_agent_cli/services/version_service.py b/src/keboola_agent_cli/services/version_service.py index cc714b1c1..12807345a 100644 --- a/src/keboola_agent_cli/services/version_service.py +++ b/src/keboola_agent_cli/services/version_service.py @@ -19,11 +19,9 @@ from .. import __version__ from ..constants import ( - APP_NAME, ENV_UPDATE_TIMEOUT, KBAGENT_GITHUB_REPO, KBAGENT_INSTALL_SOURCE, - LEGACY_APP_NAME, UPDATE_TIMEOUT_SECONDS, VERSION_CHECK_TIMEOUT, ) @@ -325,29 +323,6 @@ def summarize_install_failure(output: str | None) -> str: return lines[-1] if lines else "" -def legacy_install_advice(recovery_command: str | None) -> str | None: - """Return the steps that move a legacy-named install to ``keboola-cli``. - - Returns ``None`` for every other install, so only an install made under - the pre-0.63 distribution name sees it (issue #771). The order is - load-bearing: ``uv tool uninstall`` removes the ``kbagent`` executable even - when a newer ``keboola-cli`` entry has taken it over, so the install must - come second. - - Args: - recovery_command: The install command that recreates the tool - environment under the current name. - """ - if APP_NAME != LEGACY_APP_NAME or recovery_command is None: - return None - return ( - f"This install uses the old package name {LEGACY_APP_NAME}. " - "To move it to keboola-cli, run these two commands in this order:\n" - f" uv tool uninstall {LEGACY_APP_NAME}\n" - f" {recovery_command}" - ) - - def _fetch_kbagent_latest_version( timeout: float = VERSION_CHECK_TIMEOUT, *, include_prerelease: bool = False ) -> str | None: diff --git a/tests/test_auto_update.py b/tests/test_auto_update.py index 3cdfe4fea..a74180736 100644 --- a/tests/test_auto_update.py +++ b/tests/test_auto_update.py @@ -9,7 +9,6 @@ import pytest import keboola_agent_cli.auto_update as auto_update_module -import keboola_agent_cli.services.version_service as version_service_module from keboola_agent_cli.auto_update import ( UpdateOutcome, UpdateResult, @@ -287,13 +286,17 @@ def test_cache_fresh_with_bad_data(self): # --------------------------------------------------------------------------- # _perform_update # --------------------------------------------------------------------------- -# What uv prints when the `kbagent` executable belongs to another tool entry -# (issue #771). Captured from uv 0.12; a fake install run returns it, so no test +# What uv 0.12 prints when the startup command +# (`uv tool install --force --reinstall "keboola-cli @ "`) cannot +# download the wheel (issue #771). A fake install run returns it, so no test # ever starts a real uv. -UV_EXECUTABLE_CONFLICT = ( - "Resolved 1 package in 3ms\n" - " + keboola-cli==0.96.0\n" - "error: Executable already exists: kbagent (use `--force` to overwrite)\n" +UV_DOWNLOAD_FAILURE = ( + "error: Failed to download `keboola-cli @ https://example.test/k.whl`\n" + " cause: Failed to fetch: `https://example.test/k.whl`\n" + " cause: HTTP status client error (404 Not Found) for url (https://example.test/k.whl)\n" +) +UV_DOWNLOAD_FAILURE_CAUSE = ( + "cause: HTTP status client error (404 Not Found) for url (https://example.test/k.whl)" ) @@ -334,12 +337,12 @@ def test_failure_carries_the_installer_output(self, mock_install, mock_which): mock_install.return_value = InstallRun( status=InstallStatus.FAILED, exit_code=2, - output=UV_EXECUTABLE_CONFLICT, + output=UV_DOWNLOAD_FAILURE, log_path=Path("update.log"), ) result = _perform_update("2.0.0") assert result.outcome is UpdateOutcome.FAILED - assert result.output == UV_EXECUTABLE_CONFLICT + assert result.output == UV_DOWNLOAD_FAILURE @patch("shutil.which") @patch("keboola_agent_cli.auto_update.run_install") @@ -996,7 +999,7 @@ class TestStartupFailureBanner: @pytest.fixture def run_failed_update(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture): - def run(*, app_name: str, install_output: str) -> str: + def run(*, install_output: str) -> str: plan = KbagentUpdatePlan("1.0.0", "2.0.0", False, ("uv", "tool"), self.RECOVERY) monkeypatch.setattr(auto_update_module, "_AUTO_UPDATE_RAN", False) monkeypatch.setattr(auto_update_module, "_should_skip_all", lambda: False) @@ -1008,7 +1011,6 @@ def run(*, app_name: str, install_output: str) -> str: ) monkeypatch.setattr(auto_update_module, "_prepare_auto_kbagent_plan", lambda _: plan) monkeypatch.setattr(auto_update_module, "__version__", "1.0.0") - monkeypatch.setattr(version_service_module, "APP_NAME", app_name) monkeypatch.setattr( auto_update_module, "run_install", @@ -1026,37 +1028,16 @@ def run(*, app_name: str, install_output: str) -> str: return run def test_banner_names_the_line_uv_ended_with(self, run_failed_update): - err = run_failed_update(app_name="keboola-cli", install_output=UV_EXECUTABLE_CONFLICT) - - assert "Auto-update failed; continuing with current version." in err - assert "Cause: error: Executable already exists: kbagent" in err - assert f"Recover with: {self.RECOVERY}" in err - # Only a legacy-named install is told to uninstall anything. - assert "uv tool uninstall" not in err - - def test_legacy_install_gets_the_move_steps_in_order(self, run_failed_update): - err = run_failed_update(app_name="keboola-agent-cli", install_output=UV_EXECUTABLE_CONFLICT) - - assert "Cause: error: Executable already exists: kbagent" in err - uninstall = err.index("uv tool uninstall keboola-agent-cli") - install = err.index(self.RECOVERY) - # The uninstall also deletes the shared `kbagent` executable, so it must - # run first. - assert uninstall < install - - def test_legacy_install_with_another_failure_gets_the_recovery_command(self, run_failed_update): - # A network error on a legacy-named install is not the executable - # conflict, so the uninstall steps would not help. - err = run_failed_update( - app_name="keboola-agent-cli", - install_output="error: Failed to fetch: `https://example.test/k.whl`\n", - ) + err = run_failed_update(install_output=UV_DOWNLOAD_FAILURE) - assert "uv tool uninstall" not in err - assert f"Recover with: {self.RECOVERY}" in err + assert err.splitlines()[1:] == [ + "Auto-update failed; continuing with current version.", + f"Cause: {UV_DOWNLOAD_FAILURE_CAUSE}", + f"Recover with: {self.RECOVERY}", + ] def test_no_installer_output_prints_no_cause_line(self, run_failed_update): - err = run_failed_update(app_name="keboola-cli", install_output="") + err = run_failed_update(install_output="") assert "Cause:" not in err assert f"Recover with: {self.RECOVERY}" in err diff --git a/tests/test_version_service.py b/tests/test_version_service.py index f37fc4312..9effa4a7e 100644 --- a/tests/test_version_service.py +++ b/tests/test_version_service.py @@ -17,7 +17,6 @@ build_hardlink_retry_command, build_kbagent_upgrade_command, get_update_timeout, - legacy_install_advice, prepare_kbagent_update_plan, resolve_kbagent_wheel_url, summarize_install_failure, @@ -932,28 +931,6 @@ def test_returns_empty_string_without_output(self, output: str | None) -> None: assert summarize_install_failure(output) == "" -class TestLegacyInstallAdvice: - """Only an install under the old package name is told to move (#771).""" - - RECOVERY = "uv tool install --force --reinstall 'keboola-cli @ https://example.test/k.whl'" - - def test_legacy_install_gets_uninstall_before_install(self) -> None: - with patch(f"{VS}.APP_NAME", "keboola-agent-cli"): - advice = legacy_install_advice(self.RECOVERY) - - assert advice is not None - assert advice.index("uv tool uninstall keboola-agent-cli") < advice.index(self.RECOVERY) - - def test_current_install_gets_no_advice(self) -> None: - with patch(f"{VS}.APP_NAME", "keboola-cli"): - assert legacy_install_advice(self.RECOVERY) is None - - def test_no_recovery_command_means_no_advice(self) -> None: - # An uninstall without the install after it would delete `kbagent`. - with patch(f"{VS}.APP_NAME", "keboola-agent-cli"): - assert legacy_install_advice(None) is None - - class TestSummaryDistinguishesNotYetFromFailed: """ "Not updated" has three meanings; only one of them is a failure (#528)."""