diff --git a/README.md b/README.md index 1c7370ef..10ec7d7e 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), then the recovery command. + ## 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 657c98e6..ca043f01 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -6271,3 +6271,15 @@ WSL `open_browser` routes through `wslview`, because `webbrowser` keys its Windows handling off `sys.platform == "win32"` -- `linux` inside WSL -- and would otherwise find no handler or open a Linux-side browser that shares none of the logins the flow needs. + +## 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. `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 e608ef9a..b939c490 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 @@ -39,6 +40,7 @@ get_update_timeout, prepare_kbagent_update_plan, resolve_kbagent_wheel_url, + summarize_install_failure, ) from .update_runner import ( DeferredUpdateReport, @@ -68,6 +70,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 +283,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 +298,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 +312,32 @@ 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, 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}") + if recovery_command: + lines.append(f"Recover with: {recovery_command}") + return "\n".join(lines) + "\n" def _re_exec() -> None: @@ -594,9 +617,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 +635,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/services/version_service.py b/src/keboola_agent_cli/services/version_service.py index 326fb76d..12807345 100644 --- a/src/keboola_agent_cli/services/version_service.py +++ b/src/keboola_agent_cli/services/version_service.py @@ -312,6 +312,17 @@ 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 _fetch_kbagent_latest_version( timeout: float = VERSION_CHECK_TIMEOUT, *, include_prerelease: bool = False ) -> str | None: @@ -634,8 +645,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 92e74214..a7418073 100644 --- a/tests/test_auto_update.py +++ b/tests/test_auto_update.py @@ -11,6 +11,7 @@ import keboola_agent_cli.auto_update as auto_update_module from keboola_agent_cli.auto_update import ( UpdateOutcome, + UpdateResult, _get_cache_path, _is_cache_fresh, _is_dev_install, @@ -285,6 +286,20 @@ def test_cache_fresh_with_bad_data(self): # --------------------------------------------------------------------------- # _perform_update # --------------------------------------------------------------------------- +# 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_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)" +) + + 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 +319,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 +327,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_DOWNLOAD_FAILURE, + log_path=Path("update.log"), + ) + result = _perform_update("2.0.0") + assert result.outcome is UpdateOutcome.FAILED + assert result.output == UV_DOWNLOAD_FAILURE @patch("shutil.which") @patch("keboola_agent_cli.auto_update.run_install") @@ -322,13 +352,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 +370,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 +391,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 +411,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 +435,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 +451,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 +580,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 +605,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 +630,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 +953,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 +988,61 @@ 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(*, 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( + 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(install_output=UV_DOWNLOAD_FAILURE) + + 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(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 +1111,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 +1340,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 7ecbe212..9effa4a7 100644 --- a/tests/test_version_service.py +++ b/tests/test_version_service.py @@ -19,6 +19,7 @@ get_update_timeout, prepare_kbagent_update_plan, resolve_kbagent_wheel_url, + summarize_install_failure, ) from keboola_agent_cli.update_runner import DeferredUpdateRequest, InstallRun, InstallStatus @@ -913,6 +914,23 @@ 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 TestSummaryDistinguishesNotYetFromFailed: """ "Not updated" has three meanings; only one of them is a failure (#528)."""