diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 98a8eaee..b41e6e88 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.78.0", + "version": "0.79.0", "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/CLAUDE.md b/CLAUDE.md index 3d25d7b1..8dcab441 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -664,6 +664,11 @@ kbagent update [--beta] # source build; falls back to git+ when absent). Env `KBAGENT_UPDATE_TIMEOUT` (integer # seconds, default 300) raises the self-update subprocess timeout for the slow git+ # fallback on WSL. Bootstrap install: `curl -LsSf .../main/install.sh | sh`. +# Since 0.79.0 a STANDALONE (PyInstaller) binary (choco/winget/brew/apt/dnf/zip) refuses +# the kbagent self-update stage and reports that channel's own command instead -- a uv/pip +# reinstall would install a SECOND, unrelated kbagent that shadows it on PATH. `version +# --json` gains additive `install_channel` + `upgrade_hint`; `upgrade_command` is empty for +# a hand-unpacked archive. The keboola-mcp-server stage still runs (separate distribution). # Since 0.76.2 self-update completes discovery first, updates MCP before the terminal # exact-version full kbagent reinstall, then immediately re-executes; failures print a # copy-paste recovery command. diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 484be301..83ab0d2d 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.78.0", + "version": "0.79.0", "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/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index 4371dc45..a075247f 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -68,6 +68,9 @@ a critical failure. `"Cannot proceed safely on kbagent . Missing: . Ask user to run kbagent update, then re-invoke me."` Do not attempt the task with workarounds that use MCP strip-bug-prone tools. + **Standalone binaries do not take `kbagent update`** -- if + `kbagent --json version` carries `kbagent.install_channel`, quote its + `upgrade_command` (or `upgrade_hint` when that is empty) instead (0.79.0+). 7. **ALWAYS USE `--json`**. Every `kbagent` invocation MUST have `--json` as the first flag after `kbagent`. This makes output @@ -169,6 +172,11 @@ One-line triggers only. Full prose, exact error strings, issue numbers, and API quirks live in [`gotchas.md`](../skills/kbagent/references/gotchas.md) -- read it when a trigger fires. Each `(X.Y.Z+)` tag is the version floor. +**Upgrading kbagent itself** +- `install_channel` in `kbagent --json version` => native binary; `kbagent + update` REFUSES by design. Quote `upgrade_command` (choco/winget/brew/apt/dnf); + it is `""` for `archive`/`system`, then quote `upgrade_hint`. (0.79.0+) + **Flow / config edits** - **Conditional flows only (since 0.57.0)**: `flow` targets `keboola.flow`; diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 84e64dbe..b8ecd064 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -399,6 +399,11 @@ For detailed response parsing rules and common pitfalls, see [gotchas](reference | SQL migration (input mapping removal) | [sql-migration-workflow](references/sql-migration-workflow.md) | | **Semantic layer (metastore)** -- models, metrics, datasets, constraints, glossary; validate / export / diff / promote / build / token | [semantic-layer-workflow](references/semantic-layer-workflow.md) | | **Developer Portal** (identity CRUD, list/get apps, create/patch/upload-icon/publish/deprecate; TTY-confirm on writes) | [dev-portal-workflow](references/dev-portal-workflow.md) | +| **Config metadata** (list/get/set/delete arbitrary key-value metadata on a configuration) | [config-metadata-workflow](references/config-metadata-workflow.md) | +| **Storage descriptions** (describe bucket / table / column, batch from YAML) | [storage-describe-workflow](references/storage-describe-workflow.md) | +| **Deep column-level lineage** (`lineage build --ai`, column graph, ER + HTML output) | [lineage-deep-workflow](references/lineage-deep-workflow.md) | +| **Session permissions firewall** (`--deny-writes` / `--deny-destructive`, persisted policies, `permissions check`) | [permissions-workflow](references/permissions-workflow.md) | +| **Kai** (project-aware AI Q&A: ping / preflight / ask / chat / history) | [kai-workflow](references/kai-workflow.md) | | Response parsing gotchas | [gotchas](references/gotchas.md) | ## First-time setup diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index e53e10f1..5c372aa3 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -5,8 +5,8 @@ All commands support `--json` for structured output. Multi-project flags (`--pro ## Setup & Info - `init [--from-global] [--project ALIAS ...]` -- create local `.kbagent/` workspace in current directory; `--project ALIAS` (repeatable) copies only the named project(s) from the global config and implies `--from-global` - `doctor [--fix]` -- health check for CLI config and MCP server -- `version [--beta]` -- show version info and dependency update status. `--beta` (since v0.42.0) reports the latest pre-release (beta / rc) instead of the latest stable. Env override: `KBAGENT_INCLUDE_PRERELEASE=1` -- `update [--beta]` -- self-update to latest version. `--beta` (since v0.42.0) opts into pre-release versions (PEP 440 betas / rc, e.g. `0.43.0b1`). Default behaviour: GitHub's `/releases/latest` endpoint filters prereleases server-side, so the startup auto-update hook never silently lands on a beta. Resolver-level opt-in (`--prerelease=allow` for uv, `--pre` for pip) is added automatically when `--beta` is set +- `version [--beta]` -- show version info and dependency update status. On a standalone binary the payload carries additive `kbagent.install_channel` + `kbagent.upgrade_hint` keys and `upgrade_command` holds the channel's command (empty for a hand-unpacked archive) (since v0.79.0). `--beta` (since v0.42.0) reports the latest pre-release (beta / rc) instead of the latest stable. Env override: `KBAGENT_INCLUDE_PRERELEASE=1` +- `update [--beta]` -- self-update to latest version. `--beta` (since v0.42.0) opts into pre-release versions (PEP 440 betas / rc, e.g. `0.43.0b1`). Default behaviour: GitHub's `/releases/latest` endpoint filters prereleases server-side, so the startup auto-update hook never silently lands on a beta. Resolver-level opt-in (`--prerelease=allow` for uv, `--pre` for pip) is added automatically when `--beta` is set. **Standalone (PyInstaller) binaries refuse the self-update** and report their own channel's command instead -- a uv/pip reinstall would install a second, unrelated kbagent rather than upgrade the packaged one (since v0.79.0) - `changelog [--limit N] [--full]` -- show recent changelog (default: last 5 versions, one-line summary per version; `--full` / `-v` expands every note). After auto-update, "What's new" is printed automatically (summarised). Manual trigger: `KBAGENT_UPDATED_FROM=0.17.0 kbagent version` - `context` -- print full CLI reference for AI agents @@ -368,7 +368,7 @@ CLI parity for the `/agents` REST surface. Reads/writes `/agents.jso ## Utility - `init [--from-global] [--project ALIAS ...]` -- create local `.kbagent/` workspace (per-directory isolation); `--project ALIAS` (repeatable) copies only the named project(s) and implies `--from-global` - `doctor [--fix]` -- health checks; `--fix` auto-installs MCP server binary. Includes a `sync_secrets` check (since 0.55.0): when run inside a sync working tree (`.keboola/manifest.json`), warns if any in-sync config holds plaintext `#`-secrets (#378); `skip` outside a sync tree -- `version` -- show version and check for MCP server updates +- `version` -- show version and check for MCP server updates; on a standalone binary it advertises the native channel's upgrade command instead of `kbagent update` (v0.79.0+) - `context` -- full usage instructions for AI agents ## Global Flags diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index aaa27c72..84fa8c09 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -3112,3 +3112,73 @@ embeds the source `table` object), `snapshot-delete` (destructive: forecloses restores, source tables untouched; batch-tolerant, exit 1 on any failure). Snapshot create and restore are async storage jobs -- the CLI polls to completion, so the receipt's `table.rowsCount` is authoritative. + +## The standalone binary never self-updates; recommend its own channel (since v0.79.0) + +kbagent ships through two channels: a **Python distribution** (`uv tool install` +/ `pip`) and a **self-contained PyInstaller binary** with no Python runtime, +delivered by Chocolatey, WinGet, Homebrew, apt, dnf, or a signed zip. They are +upgraded in completely different ways, and the wrong advice is actively harmful. + +- **Never tell a native-binary user to run `uv tool install` (or `pip install`, + or `kbagent update`).** That does not upgrade the running binary at all -- it + creates a SECOND, unrelated kbagent in the uv tool directory, which usually + precedes the package manager's directory on `PATH`. The user silently starts + running a different install than the one `choco` / `brew` / `apt` tracks, + while the packaged binary stays stale. With no Python on the machine it just + fails. +- **kbagent detects this itself now.** A frozen build is identified via + `sys.frozen` / `sys._MEIPASS`, and the channel from the binary's own path. + The startup auto-update hook prints a notification instead of reinstalling + (throttled to once per version-cache TTL), and `kbagent update` refuses with + the right command rather than running uv. This includes the deferred Windows + helper from v0.78.0 -- the guard sits ahead of the `should_defer()` branch, so + a frozen binary is never scheduled for an install it cannot receive either. +- **Read the channel out of `kbagent version --json`.** A frozen build carries + two additive keys: `kbagent.install_channel` (`chocolatey` / `winget` / + `homebrew` / `debian` / `rpm` / `system` / `archive`) and + `kbagent.upgrade_hint` (always a human sentence). Both are **absent** on + uv/pip installs -- so `install_channel` present is the reliable "this is a + native binary" signal. `upgrade_command` stays runnable-or-empty: for + `archive` and `system` there is no single correct command, so it is `""` and + the sentence lives in `upgrade_hint`. **Never shell out to `upgrade_hint`**, + and check `upgrade_command` is non-empty before running it. + Do not infer it from the version string; a frozen binary reports a perfectly + normal version (PyInstaller bundles the dist metadata, so there is no + `0.0.0-dev` tell). +- **Upgrade commands by channel:** `choco upgrade keboola-cli2` · + `winget upgrade Keboola.KeboolaCLI2` · `brew upgrade keboola-cli2` · + `sudo apt-get install --only-upgrade keboola-cli2` · + `sudo dnf upgrade keboola-cli2` · hand-unpacked archive -> re-download from + the GitHub release page. Note the **package** is `keboola-cli2` while the + **binary** is `kbagent`; the PyPI distribution is a third name, `keboola-cli`. +- **keboola-mcp-server still auto-updates on a frozen build**, by design: it is + a separate Python distribution the binary only spawns as a subprocess. If the + user has no Python tooling, install-method detection returns `none` and the + stage does nothing. + +## `--json` is written as UTF-8, independent of the console codepage (since v0.78.0) + +`--json` output no longer goes through the terminal's text encoder. It is +written straight to `sys.stdout.buffer` as UTF-8, so the bytes you parse never +depend on the active console codepage. + +- **What this fixes (#546).** On a default Czech / Polish / Hungarian Windows + console (cp1250), any non-ASCII character in the payload raised + `UnicodeEncodeError` and killed the command -- an arrow in a flow name was + enough to make `kbagent --json flow list` unusable. The crash hit the two + pydantic paths (`model_dump_json` emits raw non-ASCII); `json.dumps` paths + escaped to `\uXXXX` under its `ensure_ascii` default and so survived. +- **Covers all machine output**, not just the success envelope: the error + envelope, the `kbagent --json agent run --stream` NDJSON event lines (which + use `ensure_ascii=False` on purpose so event text stays readable), and + `kbagent http`'s JSON printer. +- **Always decode as UTF-8.** Do not decode with `locale.getpreferredencoding()` + on Windows -- that is cp1250 and will mangle or fail on the same characters + the fix was written for. +- **Windows line endings changed for JSON only**: machine output now ends `LF`, + not `CRLF`, because the binary buffer does no newline translation. Human + (Rich) output is unaffected. No JSON/NDJSON parser cares, but a test that + string-compares raw stdout bytes on Windows might. +- **Captured or replaced streams fall back to the plain text write** (there is + no binary buffer to bypass), so in-process test harnesses behave as before. diff --git a/pyproject.toml b/pyproject.toml index 22276dd8..60c4419e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.78.0" +version = "0.79.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/auto_update.py b/src/keboola_agent_cli/auto_update.py index 1f8e719d..2ab427ee 100644 --- a/src/keboola_agent_cli/auto_update.py +++ b/src/keboola_agent_cli/auto_update.py @@ -30,6 +30,7 @@ VERSION_CACHE_FILENAME, VERSION_CHECK_TIMEOUT, ) +from .frozen_dist import FrozenDistribution, detect_frozen_distribution, is_frozen_build from .services.version_service import ( MCP_PACKAGE_NAME, MCP_UV_PRERELEASE_FLAG, @@ -179,7 +180,21 @@ def _is_dev_install() -> bool: Returns True if: - __version__ is '0.0.0-dev' (PackageNotFoundError fallback), or - The package was installed in editable mode (PEP 660 direct_url.json). + + A frozen (PyInstaller) build is NEVER a dev tree, whatever its bundled + metadata claims -- and the claim is routinely wrong. The release workflow + freezes from a ``uv run`` sync, which installs the project editable, and + ``--collect-all keboola_agent_cli`` copies the whole ``.dist-info`` into the + binary *including* ``direct_url.json`` with ``"editable": true``. Every + shipped binary therefore looked like a developer checkout here, which + silently disabled the entire startup hook -- including the frozen-build + notification that is supposed to replace the self-update for exactly those + users (see :mod:`keboola_agent_cli.frozen_dist`). The check must come first: + the bundled marker describes the BUILD MACHINE, not the machine running it. """ + if is_frozen_build(): + return False + if __version__ == "0.0.0-dev": return True @@ -565,6 +580,31 @@ def _schedule_deferred_update(plan: KbagentUpdatePlan) -> None: ) +def _notify_frozen_update_available( + distribution: FrozenDistribution, latest_version: str | None +) -> None: + """Report a new release to a native-binary user instead of self-updating. + + Replaces Stage 1 for frozen (PyInstaller) builds. Neither the inline + reinstall nor the deferred Windows helper can upgrade a Chocolatey / + Homebrew / apt / dnf install -- both would create an unrelated second copy + that shadows the real binary on PATH (see + :mod:`keboola_agent_cli.frozen_dist`). So we only tell the user, naming the + command their own channel actually accepts. + + Silent when already current or when the latest version is unknown (offline, + or the re-exec guard suppressed the fetch): a version banner with nothing + actionable behind it is noise. + """ + if _is_up_to_date(__version__, latest_version) is not False: + return + sys.stderr.write( + f"kbagent v{__version__} -> v{latest_version} available. Self-update is " + f"disabled for the standalone binary ({distribution.channel.value}); " + f"{distribution.upgrade_hint}\n" + ) + + def _prepare_auto_kbagent_plan(latest_version: str | None) -> KbagentUpdatePlan: """Adapt the shared plan to the startup comparison seam used by tests.""" prepared = prepare_kbagent_update_plan(latest_version) @@ -594,6 +634,19 @@ def maybe_auto_update() -> None: No re-exec is needed: the MCP server is spawned by ``tool call`` commands and the next spawn picks up the new version. + **Frozen (PyInstaller) builds replace Stage 1 with a notification.** A + native binary from Chocolatey / WinGet / Homebrew / apt / dnf is not a + uv-managed tool environment, so neither the inline reinstall nor the + deferred Windows helper applies -- both would install an unrelated second + copy instead of upgrading the running one (full rationale in + :mod:`keboola_agent_cli.frozen_dist`). **Stage 2 still runs there**, and + that is deliberate: ``keboola-mcp-server`` is a *separate* Python + distribution that a frozen kbagent only ever spawns as a subprocess, so + upgrading it neither touches nor depends on the frozen binary. A pure + binary user with no Python at all is unaffected either way -- + :func:`_detect_mcp_install_method` returns ``"none"`` and the stage + short-circuits without installing anything. + Critical invariant: **the re-exec'd process (KBAGENT_SKIP_UPDATE=1) skips ONLY Stage 1**. Stage 2 always runs, so a kbagent self-upgrade on startup leaves the user with both kbagent AND MCP refreshed in @@ -636,6 +689,12 @@ def maybe_auto_update() -> None: cached_kbagent = cache.get("latest_version") if cache else None cached_mcp = cache.get("mcp_latest_version") if cache else None skip_kbagent_stage = _should_skip_kbagent_stage() + # A frozen (PyInstaller) binary is upgraded by the package manager that + # placed it, never by us. Detected BEFORE planning so the wheel-URL HEAD + # probe inside prepare_kbagent_update_plan is skipped as well -- that + # would be a wasted network round-trip on every single startup, for a + # command this process is never allowed to run. + frozen_dist = detect_frozen_distribution() use_cached_kbagent = cache_is_fresh and isinstance(cached_kbagent, str) use_cached_mcp = cache_is_fresh and isinstance(cached_mcp, str) latest_version = ( @@ -659,7 +718,7 @@ def maybe_auto_update() -> None: mcp_plan = prepare_mcp_update_plan(mcp_latest) kbagent_plan = ( _prepare_auto_kbagent_plan(latest_version) - if not skip_kbagent_stage + if not skip_kbagent_stage and frozen_dist is None else KbagentUpdatePlan(__version__, latest_version, True, None, None) ) @@ -685,6 +744,22 @@ def maybe_auto_update() -> None: mcp_install_method=mcp_plan.install_method, ) + # Frozen builds: Stage 1 becomes a notification. Deliberately placed + # AFTER the MCP stage and the cache write -- both stay fully active (see + # this function's docstring for why MCP is still updated), and letting + # the TTL tick is what throttles the banner below. Placed BEFORE the + # `should_defer()` branch further down, so the deferred Windows helper + # is never scheduled for a binary it cannot install over either. + if frozen_dist is not None: + # Only on a run that actually refreshed the cache, i.e. at most once + # per AUTO_UPDATE_CHECK_INTERVAL. Unlike the normal path this banner + # cannot resolve itself by re-exec'ing, so without throttling it + # would print on every kbagent invocation until the user upgrades -- + # pure noise in any script that shells out to kbagent in a loop. + if not use_cached_kbagent: + _notify_frozen_update_available(frozen_dist, latest_version) + return + if kbagent_plan.up_to_date is not False: return if kbagent_plan.command is None: diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index eb0e0856..2fa5abb5 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,49 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.79.0": [ + "Fix: the standalone `kbagent` binary no longer tries to update itself with " + "`uv tool install`. kbagent ships both as a Python distribution and as a " + "self-contained PyInstaller binary delivered by Chocolatey, WinGet, Homebrew, apt, " + "dnf or a signed zip, but nothing detected the difference -- so both update paths " + "planned a uv/pip reinstall, which cannot upgrade a package-manager-owned binary. " + "It installs a SECOND, unrelated kbagent into the uv tool directory, which usually " + "precedes the package manager's directory on PATH: the user silently starts running " + "a different install than the one `choco` / `brew` / `apt` tracks, while the real " + "binary stays stale. With no Python on the machine it simply fails on every startup. " + "`sys.frozen` / `sys._MEIPASS` is now detected explicitly and the channel is " + "identified from the running binary's own path, so the self-update is replaced by a " + "notification carrying the command that channel actually accepts -- " + "`choco upgrade keboola-cli2`, `winget upgrade Keboola.KeboolaCLI2`, " + "`brew upgrade keboola-cli2`, `sudo apt-get install --only-upgrade keboola-cli2`, " + "`sudo dnf upgrade keboola-cli2`, or the GitHub release page for a hand-unpacked " + "archive. An unattributable path degrades to the release page rather than guessing. " + "This covers the deferred Windows helper added in 0.78.0 too: the guard sits ahead " + "of the `should_defer()` branch, so a frozen binary is never scheduled for an " + "install it cannot receive. uv / pip installs behave exactly as before.", + "Fix: a frozen binary is no longer mistaken for a developer checkout, which had " + "been silently disabling the startup update check inside every shipped artifact. " + "The release workflow freezes with `pyinstaller --collect-all keboola_agent_cli`, " + "and `collect_all()` is a superset of `--copy-metadata`, so the whole `.dist-info` " + "is bundled -- including `direct_url.json`, which records how the project was " + "installed on the BUILD machine. CI freezes from an editable `uv run` sync, so that " + 'file says `"editable": true` inside every released binary and the dev-install ' + "probe returned True for all of them. A frozen build is now never treated as a dev " + "tree, whatever its bundled metadata claims.", + "New: `kbagent update` on a standalone binary reports the channel's own upgrade " + "command instead of running uv, and no longer misreports that deliberate refusal as " + "a failed update. `kbagent version` advertises the same command in place of " + "`(run: kbagent update)`. Both add `install_channel` and `upgrade_hint` keys under " + "`kbagent` in `--json`; `upgrade_command` stays runnable-or-empty (it is empty for a " + "hand-unpacked archive, where the sentence lives in `upgrade_hint`), so a consumer " + "shelling out to it never executes prose. All three keys are absent for uv / pip " + "installs, leaving their JSON shape byte-identical.", + "Note: `keboola-mcp-server` still auto-updates on a frozen build, by design. It is a " + "separate Python distribution that the binary only ever spawns as a subprocess, so " + "upgrading it neither touches nor depends on the frozen kbagent. A pure-binary user " + "with no Python tooling is unaffected either way -- install-method detection returns " + "`none` and the stage does nothing.", + ], "0.78.0": [ "Fix (#546): `kbagent --json` no longer crashes with `UnicodeEncodeError` on Windows " "consoles using a non-UTF-8 codepage (cp1250 on Czech/Polish/Hungarian Windows). Any " diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 7ccc1a3d..9a477c9b 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -1467,6 +1467,13 @@ matching command. Both stages always run, regardless of whether kbagent itself needed an upgrade. The same flow runs automatically on every kbagent startup -- the explicit `update` command forces a fresh check. + Since 0.79.0 a STANDALONE (PyInstaller) binary -- Chocolatey / WinGet / + Homebrew / apt / dnf / signed zip -- refuses the kbagent stage and reports + that channel's own upgrade command instead; a uv/pip reinstall there + installs a SECOND, unrelated kbagent rather than upgrading the packaged + one. `version --json` then carries kbagent.install_channel and + kbagent.upgrade_hint; upgrade_command is empty for a hand-unpacked + archive. The MCP stage still runs -- it is a separate distribution. --beta (since 0.42.0) opts into pre-release versions (PEP 440 betas/rc, e.g. 0.43.0b1). Without --beta the auto-update path uses GitHub's /releases/latest endpoint, which excludes prereleases server-side -- diff --git a/src/keboola_agent_cli/commands/version.py b/src/keboola_agent_cli/commands/version.py index 0fda2f49..7090970e 100644 --- a/src/keboola_agent_cli/commands/version.py +++ b/src/keboola_agent_cli/commands/version.py @@ -92,7 +92,17 @@ def _format_version_panel(console: Console, data: dict) -> None: if kbagent.get("up_to_date") is False and kbagent.get("latest_version"): text.append(f" -> v{kbagent['latest_version']} available", style="yellow") - text.append(" (run: kbagent update)", style="dim") + # `install_channel` is present only for a frozen native binary, where + # `kbagent update` deliberately refuses to act -- pointing the user at + # it would just send them one step further down a dead end. The service + # already put that channel's real command in `upgrade_command`. + if kbagent.get("install_channel"): + # `upgrade_command` is empty for channels with no single runnable + # command (hand-unpacked archive); the hint sentence covers those. + action = kbagent.get("upgrade_command") or kbagent.get("upgrade_hint", "") + text.append(f" ({action})", style="dim") + else: + text.append(" (run: kbagent update)", style="dim") elif kbagent.get("up_to_date") is True: text.append(" up to date", style="green") diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index 8aecfd0d..266518b3 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -358,6 +358,49 @@ def _resolve_app_name() -> str: # lost (helper killed, machine rebooted mid-wait) and reported once. DEFERRED_UPDATE_STALE_SECONDS: int = 86400 +# --- Native (frozen / PyInstaller) distribution --- +# kbagent also ships as a self-contained PyInstaller binary with NO Python +# runtime (see build/package/ and .github/workflows/release-kbagent.yml). Such +# a binary is placed by a native package manager -- Chocolatey, WinGet, +# Homebrew, apt, dnf -- or unpacked by hand from a signed archive. It is NOT a +# uv/pip tool environment, so neither the inline nor the deferred self-update +# path above may run there: `uv tool install` would install a SECOND, unrelated +# copy of kbagent that can shadow the real binary on PATH while leaving it +# untouched (see frozen_dist.py). +# +# Package identity across every native channel. `keboola-cli2` is the +# Chocolatey package id, the Homebrew formula name (tap +# keboola/homebrew-keboola-cli2) AND the deb/rpm package name -- one literal, +# because the release workflow uses one name everywhere. The BINARY is still +# `kbagent`; this is the PACKAGE name. +NATIVE_PACKAGE_NAME: str = "keboola-cli2" +# WinGet uses its own Publisher.Package identifier form, not the package name. +NATIVE_WINGET_PACKAGE_ID: str = "Keboola.KeboolaCLI2" +# Every per-OS archive (.zip + .sha256) and the .deb / .rpm are attached to the +# GitHub Release, so the releases page is a always-valid, browsable fallback for +# a hand-unpacked install whose channel we cannot identify. +NATIVE_RELEASES_URL: str = f"https://github.com/{KBAGENT_GITHUB_REPO}/releases/latest" + +# Path markers used to identify which channel placed the running binary. Matched +# as substrings against the binary's own path, lower-cased and with backslashes +# normalized to forward slashes, so the Windows markers are testable from any OS. +NATIVE_CHOCOLATEY_PATH_MARKERS: tuple[str, ...] = ("/chocolatey/",) +NATIVE_WINGET_PATH_MARKERS: tuple[str, ...] = ("/winget/packages/", "/winget/links/") +# Homebrew keeps the real file under /Cellar/... and symlinks it into +# /bin. Both forms are matched because sys.executable may report either. +NATIVE_HOMEBREW_PATH_MARKERS: tuple[str, ...] = ( + "/cellar/", + "/opt/homebrew/", + "/linuxbrew/", +) +# nfpm installs to /usr/bin/kbagent (see build/package/nfpm.yaml) and nothing we +# ship ever writes /usr/local/bin -- which is precisely where a hand-unpacked +# archive lands. Listing it would hand those users `apt-get install +# --only-upgrade keboola-cli2` for a package that was never installed; they must +# degrade to ARCHIVE and the release page instead. Only consulted on Linux, since +# deb/rpm do not exist elsewhere. +NATIVE_SYSTEM_BIN_PREFIXES: tuple[str, ...] = ("/usr/bin/",) + # --- AI Service --- AI_SERVICE_TIMEOUT: httpx.Timeout = httpx.Timeout(connect=5.0, read=15.0, write=5.0, pool=5.0) diff --git a/src/keboola_agent_cli/frozen_dist.py b/src/keboola_agent_cli/frozen_dist.py new file mode 100644 index 00000000..08afe8fc --- /dev/null +++ b/src/keboola_agent_cli/frozen_dist.py @@ -0,0 +1,203 @@ +"""Frozen-build detection and native-distribution channel mapping. + +kbagent ships through two fundamentally different channels: + +1. **A Python distribution** (``uv tool install`` / ``pip install``) -- the + self-update path rebuilds that tool environment in place. +2. **A self-contained PyInstaller binary** with no Python runtime, delivered by + Chocolatey / WinGet / Homebrew / apt / dnf or unpacked by hand from a signed + archive (see ``build/package/`` and ``.github/workflows/release-kbagent.yml``). + +For (2) the uv/pip self-update is not merely useless, it is actively harmful: +``uv tool install --force --reinstall "keboola-cli[server] @ ..."`` creates a +**separate** uv tool environment that has nothing to do with the running binary. +The Chocolatey-managed ``kbagent.exe`` stays at the old version while a second +``kbagent`` appears in ``~/.local/bin`` -- which typically precedes the package +manager's directory on ``PATH``, so the user silently starts running a different +install than the one their package manager tracks. On a machine with no Python +at all the command simply fails and the startup banner cries wolf on every run. + +This module is the single seam both update paths consult: + +- :func:`is_frozen_build` -- am I running inside a PyInstaller bundle? +- :func:`detect_frozen_distribution` -- if so, which channel placed me and what + is the correct upgrade command for it? + +Why the version fallback in ``__init__.py`` does NOT already cover this: the +release workflow freezes with ``pyinstaller --collect-all keboola_agent_cli``, +and PyInstaller's ``collect_all()`` is a superset of ``--copy-metadata``. The +whole ``keboola_cli-.dist-info`` directory is bundled, so +``importlib.metadata.version()`` resolves a real version inside the binary +(empirically verified: a frozen ``kbagent --version`` prints ``kbagent v0.77.0``, +not the ``0.0.0-dev`` fallback). ``_is_dev_install()`` therefore does not fire. + +Worse, ``direct_url.json`` is bundled too, and it records the *build machine's* +install mode. Today CI freezes from a ``uv run`` sync, which installs the +project editable, so the bundled ``direct_url.json`` carries +``"editable": true`` and ``_is_dev_install()`` happens to return True -- the +startup hook is suppressed by accident, not by design. Freezing from a +non-editable install (``uv pip install .``, a wheel, ``UV_NO_EDITABLE=1``, or a +future uv default change) flips that flag and every shipped binary would start +running ``uv tool install`` on startup. Both states were reproduced locally. +Hence an explicit guard rather than reliance on that accident. +""" + +import enum +import shutil +import sys +from dataclasses import dataclass + +from .constants import ( + NATIVE_CHOCOLATEY_PATH_MARKERS, + NATIVE_HOMEBREW_PATH_MARKERS, + NATIVE_PACKAGE_NAME, + NATIVE_RELEASES_URL, + NATIVE_SYSTEM_BIN_PREFIXES, + NATIVE_WINGET_PACKAGE_ID, + NATIVE_WINGET_PATH_MARKERS, +) + + +class FrozenChannel(enum.StrEnum): + """Native packaging channel that placed a frozen kbagent binary. + + :class:`enum.StrEnum` so members serialize straight into ``--json`` output + and compare equal to their plain-string form. + """ + + CHOCOLATEY = "chocolatey" + WINGET = "winget" + HOMEBREW = "homebrew" + DEBIAN = "debian" + RPM = "rpm" + #: A Linux system-bin install whose package manager could not be identified. + SYSTEM = "system" + #: Hand-unpacked archive (or any path we cannot attribute to a channel). + ARCHIVE = "archive" + + +#: Exact upgrade command per channel. Mirrors the ``mcp_upgrade_cmd_by_method`` +#: mapping in ``version_service.py``: package identity comes from constants, the +#: verb is inherent to the tool. Channels absent from this map have no single +#: correct command -- they fall back to :data:`NATIVE_RELEASES_URL`. +_UPGRADE_COMMANDS: dict[FrozenChannel, str] = { + FrozenChannel.CHOCOLATEY: f"choco upgrade {NATIVE_PACKAGE_NAME}", + FrozenChannel.WINGET: f"winget upgrade {NATIVE_WINGET_PACKAGE_ID}", + FrozenChannel.HOMEBREW: f"brew upgrade {NATIVE_PACKAGE_NAME}", + FrozenChannel.DEBIAN: f"sudo apt-get install --only-upgrade {NATIVE_PACKAGE_NAME}", + FrozenChannel.RPM: f"sudo dnf upgrade {NATIVE_PACKAGE_NAME}", +} + + +@dataclass(frozen=True) +class FrozenDistribution: + """How the running frozen binary was installed, and how to upgrade it. + + Attributes: + channel: Detected packaging channel. + binary_path: The running binary's own path (``sys.executable``), kept so + failure reports can show *which* install the guard matched on. + upgrade_command: Exact copy-pasteable command, or None when the channel + has no single correct one (hand-unpacked archive / unidentified + system package). Machine-readable half of the pair. + upgrade_hint: Always-populated human sentence naming the right action. + """ + + channel: FrozenChannel + binary_path: str + upgrade_command: str | None + upgrade_hint: str + + +def is_frozen_build() -> bool: + """Whether this process is a PyInstaller-frozen binary. + + ``sys.frozen`` is the documented marker and ``sys._MEIPASS`` (the unpacked + bundle directory) is checked too: the pair is the standard belt-and-braces + probe, and other freezers set only one of them. + """ + return bool(getattr(sys, "frozen", False)) or hasattr(sys, "_MEIPASS") + + +def _classify_system_package_manager() -> FrozenChannel: + """Distinguish a deb-based from an rpm-based host for a system-bin install. + + ``build/package/nfpm.yaml`` produces both a ``.deb`` and an ``.rpm`` that + install to the same ``/usr/bin/kbagent``, so the path alone cannot say which + one is present. Probing for the package manager binary is the same idiom + ``version_service._detect_mcp_install_method`` uses for uv/pip. + """ + if shutil.which("apt-get") is not None: + return FrozenChannel.DEBIAN + if shutil.which("dnf") is not None or shutil.which("yum") is not None: + return FrozenChannel.RPM + return FrozenChannel.SYSTEM + + +def _classify_channel(binary_path: str, *, platform: str | None = None) -> FrozenChannel: + """Map the running binary's own path to the channel that placed it. + + Args: + binary_path: Path of the running executable (``sys.executable``). + platform: ``sys.platform`` override. Keyword-only and injectable so the + per-OS branches are testable from any host. + + Returns: + The detected channel, or :attr:`FrozenChannel.ARCHIVE` when the path + cannot be attributed -- never a wrong-but-plausible guess. + """ + resolved_platform = platform if platform is not None else sys.platform + # Lower-case with forward slashes so one marker set matches every OS. + normalized = binary_path.replace("\\", "/").casefold() + + if any(marker in normalized for marker in NATIVE_CHOCOLATEY_PATH_MARKERS): + return FrozenChannel.CHOCOLATEY + if any(marker in normalized for marker in NATIVE_WINGET_PATH_MARKERS): + return FrozenChannel.WINGET + if any(marker in normalized for marker in NATIVE_HOMEBREW_PATH_MARKERS): + return FrozenChannel.HOMEBREW + # deb/rpm are Linux-only. On macOS /usr/local/bin holds a hand-unpacked + # archive (the Homebrew formula refuses Intel Macs outright), so claiming a + # system package there would send the user to a package manager they do not + # have. + if resolved_platform.startswith("linux") and any( + normalized.startswith(prefix) for prefix in NATIVE_SYSTEM_BIN_PREFIXES + ): + return _classify_system_package_manager() + return FrozenChannel.ARCHIVE + + +def _build_upgrade_hint(channel: FrozenChannel, upgrade_command: str | None) -> str: + """Render the human-facing upgrade sentence for a channel.""" + if upgrade_command is not None: + return f"upgrade it with: {upgrade_command}" + if channel is FrozenChannel.SYSTEM: + return ( + f"upgrade the '{NATIVE_PACKAGE_NAME}' system package with your " + f"distribution's package manager" + ) + return f"re-download the signed archive from {NATIVE_RELEASES_URL}" + + +def detect_frozen_distribution() -> FrozenDistribution | None: + """Describe the native install backing this process. + + Returns: + A :class:`FrozenDistribution` when running as a frozen binary, or None + for a normal Python (uv / pip / editable) install -- in which case the + regular uv/pip self-update path is correct and must proceed untouched. + """ + if not is_frozen_build(): + return None + # For a PyInstaller onefile build sys.executable is the binary itself (the + # unpacked bundle lives in sys._MEIPASS), which is exactly the path whose + # location identifies the channel. Verified against a real frozen build. + binary_path = sys.executable or "" + channel = _classify_channel(binary_path) + upgrade_command = _UPGRADE_COMMANDS.get(channel) + return FrozenDistribution( + channel=channel, + binary_path=binary_path, + upgrade_command=upgrade_command, + upgrade_hint=_build_upgrade_hint(channel, upgrade_command), + ) diff --git a/src/keboola_agent_cli/services/version_service.py b/src/keboola_agent_cli/services/version_service.py index 023fb969..d73133bb 100644 --- a/src/keboola_agent_cli/services/version_service.py +++ b/src/keboola_agent_cli/services/version_service.py @@ -33,6 +33,7 @@ UPDATE_TIMEOUT_SECONDS, VERSION_CHECK_TIMEOUT, ) +from ..frozen_dist import FrozenDistribution, detect_frozen_distribution from ..update_runner import ( DeferredUpdateRequest, InstallStatus, @@ -57,6 +58,15 @@ class KbagentUpdatePlan: up_to_date: bool | None command: tuple[str, ...] | None recovery_command: str | None + #: Set only when running as a frozen (PyInstaller) native binary, in which + #: case ``command`` and ``recovery_command`` are deliberately None: a uv/pip + #: reinstall cannot upgrade a Chocolatey / Homebrew / apt / dnf install and + #: would create an unrelated second copy instead (see + #: :mod:`keboola_agent_cli.frozen_dist`). Consumers must report this + #: channel's own upgrade command rather than attempting the install -- + #: including the deferred Windows path, which is equally inapplicable. + #: Defaulted so the existing positional constructions stay valid. + frozen_distribution: FrozenDistribution | None = None @dataclass(frozen=True) @@ -728,10 +738,21 @@ def _is_up_to_date(local: str | None, latest: str | None) -> bool | None: def prepare_kbagent_update_plan( latest_version: str | None, *, include_prerelease: bool = False ) -> KbagentUpdatePlan: - """Prepare the terminal self-reinstall without mutating any environment.""" + """Prepare the terminal self-reinstall without mutating any environment. + + For a frozen (PyInstaller) binary no install command is produced at all -- + neither ``command`` nor ``recovery_command``. Both would be uv/pip + invocations that install a *separate* copy rather than upgrading the running + binary, so the plan instead carries the detected + :class:`~keboola_agent_cli.frozen_dist.FrozenDistribution` and callers report + that channel's upgrade command. A ``command`` of None also means the + deferred Windows helper is never handed anything to install. The wheel-URL + HEAD probe is skipped too, since its only purpose is building that command. + """ up_to_date = _is_up_to_date(__version__, latest_version) + frozen_distribution = detect_frozen_distribution() command: tuple[str, ...] | None = None - if up_to_date is False and latest_version is not None: + if up_to_date is False and latest_version is not None and frozen_distribution is None: wheel_url = resolve_kbagent_wheel_url(latest_version) built = build_kbagent_upgrade_command( prerelease=include_prerelease, @@ -745,8 +766,11 @@ def prepare_kbagent_update_plan( up_to_date=up_to_date, command=command, recovery_command=( - _recovery_command(command, latest_version) if up_to_date is False else None + _recovery_command(command, latest_version) + if up_to_date is False and frozen_distribution is None + else None ), + frozen_distribution=frozen_distribution, ) @@ -878,28 +902,50 @@ def get_versions(self, *, include_prerelease: bool = False) -> dict[str, Any]: # latest_version advertised a beta tag -- silently landing on the # wrong version. kbagent_target_version = kbagent_latest - # Mirror the _update_kbagent path (issue #353, NB-1): advertise the - # prebuilt-wheel install command when the asset exists, so a programmatic - # consumer copy-pasting `upgrade_command` from `kbagent version --json` - # gets the fast path too instead of a slow git+ source build. - kbagent_wheel_url = resolve_kbagent_wheel_url(kbagent_latest) - kbagent_upgrade_cmd = build_kbagent_upgrade_command( - prerelease=include_prerelease, - target_version=kbagent_target_version, - wheel_url=kbagent_wheel_url, - ) - kbagent_upgrade_str = ( - " ".join(kbagent_upgrade_cmd) - if kbagent_upgrade_cmd is not None - else f"uv tool install --upgrade {KBAGENT_INSTALL_SOURCE}" - ) + # A frozen native binary is upgraded by the package manager that placed + # it. Advertising the uv/pip command here would hand every Chocolatey / + # Homebrew / apt / dnf user a copy-pasteable way to install a SECOND, + # unrelated kbagent (see frozen_dist.py) -- so the channel's own command + # replaces it, and the wheel-URL HEAD probe is skipped as dead weight. + frozen_distribution = detect_frozen_distribution() + if frozen_distribution is not None: + # Keep `upgrade_command` a RUNNABLE command or nothing. A consumer + # that shells out to it verbatim -- the use the gotchas entry + # documents -- must never be handed the prose hint. Channels with no + # single command (hand-unpacked archive, unidentified system + # package) carry the sentence in `upgrade_hint` instead. + kbagent_upgrade_str = frozen_distribution.upgrade_command or "" + else: + # Mirror the _update_kbagent path (issue #353, NB-1): advertise the + # prebuilt-wheel install command when the asset exists, so a programmatic + # consumer copy-pasting `upgrade_command` from `kbagent version --json` + # gets the fast path too instead of a slow git+ source build. + kbagent_wheel_url = resolve_kbagent_wheel_url(kbagent_latest) + kbagent_upgrade_cmd = build_kbagent_upgrade_command( + prerelease=include_prerelease, + target_version=kbagent_target_version, + wheel_url=kbagent_wheel_url, + ) + kbagent_upgrade_str = ( + " ".join(kbagent_upgrade_cmd) + if kbagent_upgrade_cmd is not None + else f"uv tool install --upgrade {KBAGENT_INSTALL_SOURCE}" + ) + kbagent_entry: dict[str, Any] = { + "version": __version__, + "latest_version": kbagent_latest, + "up_to_date": kbagent_up_to_date, + "upgrade_command": kbagent_upgrade_str, + } + # Additive keys, present ONLY on a frozen build, so the JSON shape every + # existing uv/pip consumer sees stays byte-identical. `upgrade_hint` is + # always a human sentence; `upgrade_command` is empty when the channel + # has no single runnable command. + if frozen_distribution is not None: + kbagent_entry["install_channel"] = frozen_distribution.channel.value + kbagent_entry["upgrade_hint"] = frozen_distribution.upgrade_hint return { - "kbagent": { - "version": __version__, - "latest_version": kbagent_latest, - "up_to_date": kbagent_up_to_date, - "upgrade_command": kbagent_upgrade_str, - }, + "kbagent": kbagent_entry, "dependencies": [ mcp_entry, ], @@ -1011,6 +1057,21 @@ def _compose_update_summary( ) 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("install_channel"): + # Frozen native binary -- behind, but NOT a failed update: we never + # attempted one. Must precede the failure branch below, which would + # otherwise report this deliberate refusal as "update FAILED". + channel = kbagent_result.get("install_channel") + action = kbagent_result.get("upgrade_command") or "see the release page" + latest = kbagent_result.get("latest_version") + # `latest` is None when the release lookup failed (offline). The + # refusal is still worth reporting -- the user asked for an update + # and got none -- but "-> vNone available" is not. + target = f" -> v{latest} available" if latest else " (latest version unknown)" + parts.append( + f"kbagent v{kbagent_result.get('current_version')}{target} " + f"(standalone {channel} binary; {action})" + ) 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}") @@ -1055,6 +1116,29 @@ def _update_kbagent(plan: KbagentUpdatePlan) -> dict[str, Any]: "message": f"kbagent v{old_version} is already up to date.", } + # Frozen binary: refuse the self-update and name the real channel. This + # MUST precede BOTH branches below. The `command is None` branch would + # tell the user to run `uv tool install --force --reinstall`, and the + # deferred-helper branch after it is equally inapplicable -- neither can + # upgrade a Chocolatey / Homebrew / apt / dnf install, they just create a + # second, unrelated kbagent that shadows theirs on PATH. + if plan.frozen_distribution is not None: + frozen = plan.frozen_distribution + return { + "planned": True, + "updated": False, + "up_to_date": up_to_date, + "current_version": old_version, + "latest_version": kbagent_latest, + "install_channel": frozen.channel.value, + "upgrade_command": frozen.upgrade_command, + "message": ( + f"kbagent v{old_version} is a standalone binary installed via " + f"{frozen.channel.value}; it cannot update itself -- " + f"{frozen.upgrade_hint}" + ), + } + if plan.command is None: return { "planned": True, diff --git a/tests/test_auto_update.py b/tests/test_auto_update.py index 78ee46d8..95ff9ac9 100644 --- a/tests/test_auto_update.py +++ b/tests/test_auto_update.py @@ -25,6 +25,7 @@ report_finished_deferred_update, ) from keboola_agent_cli.constants import ENV_AUTO_UPDATE, ENV_SKIP_UPDATE, MCP_UPGRADE_TIMEOUT +from keboola_agent_cli.frozen_dist import FrozenChannel, FrozenDistribution from keboola_agent_cli.services.version_service import KbagentUpdatePlan, McpUpdatePlan from keboola_agent_cli.update_runner import ( DeferredUpdateReport, @@ -1315,3 +1316,248 @@ def test_reported_even_when_the_run_will_not_update(self, mock_report, mock_skip auto_update_module._AUTO_UPDATE_RAN = False maybe_auto_update() mock_report.assert_called_once() + + +# --------------------------------------------------------------------------- +# Frozen (PyInstaller) build guard +# --------------------------------------------------------------------------- + + +class TestFrozenBuildGuard: + """The startup hook must never uv/pip-reinstall a native binary. + + A frozen kbagent comes from Chocolatey / WinGet / Homebrew / apt / dnf. The + normal Stage 1 reinstall would create a SEPARATE uv tool environment that + shadows the real binary on PATH while leaving it stale, so Stage 1 is + replaced by a notification naming that channel's own upgrade command. + + These tests are the guard's only protection: before this change the hook was + suppressed for frozen builds purely by accident (CI freezes from an editable + install, so the bundled ``direct_url.json`` made ``_is_dev_install()`` return + True). Freezing from a non-editable install flips that and the reinstall + fires -- reproduced against real PyInstaller binaries. + """ + + CHOCO_DIST = FrozenDistribution( + channel=FrozenChannel.CHOCOLATEY, + binary_path=r"C:\ProgramData\chocolatey\lib\keboola-cli2\tools\kbagent.exe", + upgrade_command="choco upgrade keboola-cli2", + upgrade_hint="upgrade it with: choco upgrade keboola-cli2", + ) + + @pytest.fixture(autouse=True) + def _isolated_flow(self): + """Drive the orchestrator down the active path with MCP stubbed out.""" + auto_update_module._AUTO_UPDATE_RAN = False + with ( + patch("keboola_agent_cli.auto_update._should_skip_all", return_value=False), + patch("keboola_agent_cli.auto_update._should_skip_kbagent_stage", return_value=False), + patch("keboola_agent_cli.auto_update._fetch_mcp_latest_version", return_value=None), + patch("keboola_agent_cli.auto_update.prepare_mcp_update_plan"), + patch("keboola_agent_cli.auto_update._apply_prepared_mcp_update"), + patch("keboola_agent_cli.auto_update._write_cache"), + ): + yield + + def _run_frozen(self, *, cached: bool, latest: str | None = "99.0.0"): + """Run the hook as a frozen Chocolatey binary; return the patch mocks.""" + cache = {"last_check": time.time(), "latest_version": latest} if cached else None + with ( + patch( + "keboola_agent_cli.auto_update.detect_frozen_distribution", + return_value=self.CHOCO_DIST, + ), + patch("keboola_agent_cli.auto_update._read_cache", return_value=cache), + patch("keboola_agent_cli.auto_update._is_cache_fresh", return_value=cached), + patch( + "keboola_agent_cli.auto_update._fetch_kbagent_latest_version", + return_value=latest, + ), + patch("keboola_agent_cli.auto_update._prepare_auto_kbagent_plan") as mock_plan, + patch("keboola_agent_cli.auto_update._perform_update") as mock_perform, + patch("keboola_agent_cli.auto_update._re_exec") as mock_reexec, + patch("keboola_agent_cli.auto_update._schedule_deferred_update") as mock_defer, + patch("keboola_agent_cli.auto_update.should_defer", return_value=True), + ): + maybe_auto_update() + return mock_plan, mock_perform, mock_reexec, mock_defer + + def test_frozen_never_installs_and_never_re_execs(self, capsys): + """The core guarantee: nothing installed, nothing re-exec'd, nothing scheduled. + + ``should_defer()`` is forced True inside the helper, so if the frozen + branch ever slipped below the issue #528 deferral the Windows helper + would be scheduled here and this test would catch it. + """ + mock_plan, mock_perform, mock_reexec, mock_defer = self._run_frozen(cached=False) + mock_perform.assert_not_called() + mock_reexec.assert_not_called() + mock_defer.assert_not_called() + # Planning is short-circuited too -- it would spend a wheel-URL HEAD + # probe on every startup building a command we refuse to run. + mock_plan.assert_not_called() + + def test_frozen_notification_names_the_real_channel(self, capsys): + self._run_frozen(cached=False) + stderr = capsys.readouterr().err + assert "choco upgrade keboola-cli2" in stderr + assert "99.0.0" in stderr + # The exact bug this guards against. + assert "uv tool install" not in stderr + + def test_frozen_notification_throttled_to_one_per_cache_ttl(self, capsys): + """A banner that cannot resolve itself must not print on every command. + + Unlike the normal path there is no re-exec to end the loop, so without + throttling every kbagent invocation would nag until the user upgrades. + """ + self._run_frozen(cached=True) + assert capsys.readouterr().err == "" + + def test_frozen_and_current_is_silent(self, capsys): + """No banner when the native binary is already the latest release.""" + from keboola_agent_cli import __version__ + + self._run_frozen(cached=False, latest=__version__) + assert capsys.readouterr().err == "" + + def test_frozen_with_unknown_latest_is_silent(self, capsys): + """Offline: a version banner with nothing actionable behind it is noise.""" + self._run_frozen(cached=False, latest=None) + assert capsys.readouterr().err == "" + + def test_frozen_still_updates_mcp(self): + """Documented decision: Stage 2 runs on frozen builds. + + keboola-mcp-server is a separate Python distribution that a frozen + kbagent only ever spawns as a subprocess, so upgrading it neither + touches nor depends on the frozen binary. + """ + with ( + patch( + "keboola_agent_cli.auto_update.detect_frozen_distribution", + return_value=self.CHOCO_DIST, + ), + patch("keboola_agent_cli.auto_update._read_cache", return_value=None), + patch( + "keboola_agent_cli.auto_update._fetch_kbagent_latest_version", + return_value="99.0.0", + ), + patch("keboola_agent_cli.auto_update._perform_update"), + patch("keboola_agent_cli.auto_update._apply_prepared_mcp_update") as mock_mcp, + ): + maybe_auto_update() + mock_mcp.assert_called_once() + + def test_non_frozen_install_is_unaffected(self, capsys): + """Regression guard: the uv/pip path must still self-update and re-exec.""" + plan = KbagentUpdatePlan( + current_version="0.1.0", + latest_version="99.0.0", + up_to_date=False, + command=("uv", "tool", "install", "--force", "--reinstall", "keboola-cli"), + recovery_command="uv tool install --force --reinstall keboola-cli", + ) + with ( + patch( + "keboola_agent_cli.auto_update.detect_frozen_distribution", + return_value=None, + ), + patch("keboola_agent_cli.auto_update._read_cache", return_value=None), + patch( + "keboola_agent_cli.auto_update._fetch_kbagent_latest_version", + return_value="99.0.0", + ), + 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, + ) as mock_perform, + patch("keboola_agent_cli.auto_update._re_exec") as mock_reexec, + ): + maybe_auto_update() + mock_perform.assert_called_once() + mock_reexec.assert_called_once() + + +class TestFrozenBuildIsNotADevInstall: + """A frozen binary must never be mistaken for a developer checkout. + + This is the gap that made the whole frozen guard invisible on the artifacts + CI actually ships. `--collect-all keboola_agent_cli` bundles the entire + `.dist-info`, and the release workflow freezes from a `uv run` editable + sync -- so every shipped binary carries `direct_url.json` with + `"editable": true`. `_is_dev_install()` read that and returned True, + `_should_skip_all()` bailed out of `maybe_auto_update()` before the frozen + branch, and the notification meant for exactly those users never printed. + The bundled marker describes the BUILD MACHINE, not the running one. + """ + + @staticmethod + def _editable_dist() -> MagicMock: + dist = MagicMock() + dist.read_text.return_value = json.dumps( + {"url": "file:///build/machine", "dir_info": {"editable": True}} + ) + return dist + + def test_frozen_overrides_the_bundled_editable_marker(self): + with ( + patch("keboola_agent_cli.auto_update.is_frozen_build", return_value=True), + patch("keboola_agent_cli.auto_update.__version__", "1.0.0"), + patch("keboola_agent_cli.auto_update.distribution", return_value=self._editable_dist()), + ): + assert _is_dev_install() is False + + def test_frozen_overrides_the_dev_version_fallback(self): + """Metadata missing entirely is still a shipped artifact, not a dev tree.""" + with ( + patch("keboola_agent_cli.auto_update.is_frozen_build", return_value=True), + patch("keboola_agent_cli.auto_update.__version__", "0.0.0-dev"), + ): + assert _is_dev_install() is False + + def test_non_frozen_editable_is_still_a_dev_install(self): + """The original behaviour must survive for real developer checkouts.""" + with ( + patch("keboola_agent_cli.auto_update.is_frozen_build", return_value=False), + patch("keboola_agent_cli.auto_update.__version__", "1.0.0"), + patch("keboola_agent_cli.auto_update.distribution", return_value=self._editable_dist()), + ): + assert _is_dev_install() is True + + def test_notification_survives_the_real_shipped_configuration(self, capsys): + """End-to-end: editable marker + frozen build still reaches the banner. + + Exercises the REAL `_should_skip_all()` / `_is_dev_install()` chain + rather than stubbing it, because stubbing that chain is precisely what + hid the bug. + """ + auto_update_module._AUTO_UPDATE_RAN = False + dist = FrozenDistribution( + channel=FrozenChannel.CHOCOLATEY, + binary_path=r"C:\ProgramData\chocolatey\lib\keboola-cli2\tools\kbagent.exe", + upgrade_command="choco upgrade keboola-cli2", + upgrade_hint="upgrade it with: choco upgrade keboola-cli2", + ) + with ( + patch("keboola_agent_cli.auto_update.is_frozen_build", return_value=True), + patch("keboola_agent_cli.auto_update.distribution", return_value=self._editable_dist()), + patch("keboola_agent_cli.auto_update.detect_frozen_distribution", return_value=dist), + patch("keboola_agent_cli.auto_update._read_cache", return_value=None), + patch("sys.argv", ["kbagent", "config", "list"]), + patch( + "keboola_agent_cli.auto_update._fetch_kbagent_latest_version", + return_value="99.0.0", + ), + patch("keboola_agent_cli.auto_update._fetch_mcp_latest_version", return_value=None), + patch("keboola_agent_cli.auto_update.prepare_mcp_update_plan"), + patch("keboola_agent_cli.auto_update._apply_prepared_mcp_update"), + patch("keboola_agent_cli.auto_update._write_cache"), + patch("keboola_agent_cli.auto_update._perform_update") as mock_perform, + ): + maybe_auto_update() + stderr = capsys.readouterr().err + assert "choco upgrade keboola-cli2" in stderr + assert "uv tool install" not in stderr + mock_perform.assert_not_called() diff --git a/tests/test_frozen_dist.py b/tests/test_frozen_dist.py new file mode 100644 index 00000000..ddeb99be --- /dev/null +++ b/tests/test_frozen_dist.py @@ -0,0 +1,222 @@ +"""Tests for frozen-build detection and native-channel mapping. + +Guards the invariant behind :mod:`keboola_agent_cli.frozen_dist`: a kbagent +delivered as a PyInstaller binary must never be handed a uv/pip install command, +because that installs a SECOND, unrelated copy instead of upgrading the running +one. See the module docstring for the empirical background. +""" + +import sys +from unittest.mock import patch + +import pytest + +from keboola_agent_cli.constants import ( + NATIVE_PACKAGE_NAME, + NATIVE_RELEASES_URL, + NATIVE_WINGET_PACKAGE_ID, +) +from keboola_agent_cli.frozen_dist import ( + _UPGRADE_COMMANDS, + FrozenChannel, + _classify_channel, + detect_frozen_distribution, + is_frozen_build, +) + +# Representative real install locations per channel. The Windows entries keep +# native backslashes on purpose -- normalization is part of what is under test. +CHOCOLATEY_PATH = r"C:\ProgramData\chocolatey\lib\keboola-cli2\tools\kbagent.exe" +WINGET_PATH = ( + r"C:\Users\me\AppData\Local\Microsoft\WinGet\Packages" + r"\Keboola.KeboolaCLI2_Microsoft.Winget.Source_8wekyb3d8bbwe\kbagent.exe" +) +HOMEBREW_CELLAR_PATH = "/opt/homebrew/Cellar/keboola-cli2/0.77.0/bin/kbagent" +HOMEBREW_LINK_PATH = "/opt/homebrew/bin/kbagent" +LINUXBREW_PATH = "/home/linuxbrew/.linuxbrew/bin/kbagent" +SYSTEM_PATH = "/usr/bin/kbagent" + + +def _which_only(*available: str): + """Build a ``shutil.which`` stub where only ``available`` binaries exist.""" + + def fake_which(name: str, *args, **kwargs) -> str | None: + return f"/usr/bin/{name}" if name in available else None + + return fake_which + + +class TestIsFrozenBuild: + """The two PyInstaller markers, independently and together.""" + + def test_plain_python_is_not_frozen(self): + """The test suite itself runs under a normal interpreter.""" + assert is_frozen_build() is False + + def test_sys_frozen_marker(self, monkeypatch): + monkeypatch.setattr(sys, "frozen", True, raising=False) + assert is_frozen_build() is True + + def test_meipass_marker_alone(self, monkeypatch): + """``sys._MEIPASS`` without ``sys.frozen`` still counts.""" + monkeypatch.setattr(sys, "_MEIPASS", "/tmp/_MEI123", raising=False) + assert is_frozen_build() is True + + def test_frozen_false_is_not_frozen(self, monkeypatch): + """A falsy ``sys.frozen`` must not be read as truthy.""" + monkeypatch.setattr(sys, "frozen", False, raising=False) + assert is_frozen_build() is False + + +class TestClassifyChannel: + """Path -> channel mapping, exercised across OSes from any host.""" + + @pytest.mark.parametrize( + ("binary_path", "platform", "expected"), + [ + (CHOCOLATEY_PATH, "win32", FrozenChannel.CHOCOLATEY), + (WINGET_PATH, "win32", FrozenChannel.WINGET), + (HOMEBREW_CELLAR_PATH, "darwin", FrozenChannel.HOMEBREW), + # sys.executable may report the symlink rather than the Cellar file. + (HOMEBREW_LINK_PATH, "darwin", FrozenChannel.HOMEBREW), + (LINUXBREW_PATH, "linux", FrozenChannel.HOMEBREW), + # Unattributable paths degrade to ARCHIVE, never to a wrong guess. + ("/Users/me/Downloads/kbagent", "darwin", FrozenChannel.ARCHIVE), + ("/home/me/.local/bin/kbagent", "linux", FrozenChannel.ARCHIVE), + ], + ) + def test_channel_detected(self, binary_path, platform, expected): + assert _classify_channel(binary_path, platform=platform) is expected + + def test_windows_path_case_is_ignored(self): + """Windows paths are case-insensitive; detection must be too.""" + assert ( + _classify_channel(CHOCOLATEY_PATH.upper(), platform="win32") is FrozenChannel.CHOCOLATEY + ) + + @pytest.mark.parametrize("platform", ["darwin", "linux"]) + def test_usr_local_bin_is_an_archive_not_a_system_package(self, platform): + """/usr/local/bin is a hand-unpacked archive on EVERY platform. + + nfpm installs the deb/rpm exclusively to /usr/bin (build/package/ + nfpm.yaml); nothing we ship ever writes /usr/local/bin. Attributing it + to the system package manager would hand a Debian/Ubuntu user + `apt-get install --only-upgrade keboola-cli2` for a package that was + never installed -- it fails with "unable to locate package" and points + nowhere. The Linux case is the one that bit: `apt-get` exists on + essentially every such host, so the misattribution was silent. + """ + with patch("keboola_agent_cli.frozen_dist.shutil.which", _which_only("apt-get")): + assert _classify_channel("/usr/local/bin/kbagent", platform=platform) is ( + FrozenChannel.ARCHIVE + ) + + def test_empty_path_degrades_to_archive(self): + """A missing sys.executable must not raise.""" + assert _classify_channel("", platform="linux") is FrozenChannel.ARCHIVE + + +class TestLinuxSystemPackageManager: + """/usr/bin on Linux is deb or rpm -- resolved by probing the manager.""" + + def test_apt_host_is_debian(self): + with patch("keboola_agent_cli.frozen_dist.shutil.which", _which_only("apt-get")): + assert _classify_channel(SYSTEM_PATH, platform="linux") is FrozenChannel.DEBIAN + + def test_dnf_host_is_rpm(self): + with patch("keboola_agent_cli.frozen_dist.shutil.which", _which_only("dnf")): + assert _classify_channel(SYSTEM_PATH, platform="linux") is FrozenChannel.RPM + + def test_yum_host_is_rpm(self): + """Older RHEL/CentOS ship yum without dnf.""" + with patch("keboola_agent_cli.frozen_dist.shutil.which", _which_only("yum")): + assert _classify_channel(SYSTEM_PATH, platform="linux") is FrozenChannel.RPM + + def test_apt_wins_over_dnf_when_both_present(self): + """Deterministic on a host carrying both (e.g. via a compat package).""" + with patch("keboola_agent_cli.frozen_dist.shutil.which", _which_only("apt-get", "dnf")): + assert _classify_channel(SYSTEM_PATH, platform="linux") is FrozenChannel.DEBIAN + + def test_no_package_manager_stays_generic(self): + """Never invent a manager we could not find.""" + with patch("keboola_agent_cli.frozen_dist.shutil.which", _which_only()): + assert _classify_channel(SYSTEM_PATH, platform="linux") is FrozenChannel.SYSTEM + + +class TestDetectFrozenDistribution: + """The public entry point consumed by both update paths.""" + + def test_non_frozen_returns_none(self): + """A normal Python install must keep the uv/pip path untouched.""" + assert detect_frozen_distribution() is None + + def test_frozen_chocolatey(self, monkeypatch): + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", CHOCOLATEY_PATH) + dist = detect_frozen_distribution() + assert dist is not None + assert dist.channel is FrozenChannel.CHOCOLATEY + assert dist.upgrade_command == f"choco upgrade {NATIVE_PACKAGE_NAME}" + assert dist.upgrade_hint.endswith(f"choco upgrade {NATIVE_PACKAGE_NAME}") + assert dist.binary_path == CHOCOLATEY_PATH + + def test_frozen_homebrew(self, monkeypatch): + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", HOMEBREW_CELLAR_PATH) + dist = detect_frozen_distribution() + assert dist is not None + assert dist.upgrade_command == f"brew upgrade {NATIVE_PACKAGE_NAME}" + + def test_frozen_winget_uses_publisher_qualified_id(self, monkeypatch): + """WinGet addresses the package by Publisher.Package, not by name.""" + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", WINGET_PATH) + dist = detect_frozen_distribution() + assert dist is not None + assert dist.upgrade_command == f"winget upgrade {NATIVE_WINGET_PACKAGE_ID}" + + def test_frozen_archive_has_no_command_but_a_usable_hint(self, monkeypatch): + """No package manager owns this install -- point at the release page.""" + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", "/Users/me/bin/kbagent") + dist = detect_frozen_distribution() + assert dist is not None + assert dist.channel is FrozenChannel.ARCHIVE + assert dist.upgrade_command is None + assert NATIVE_RELEASES_URL in dist.upgrade_hint + + def test_unidentified_system_package_hint_names_the_package(self, monkeypatch): + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", SYSTEM_PATH) + monkeypatch.setattr(sys, "platform", "linux") + with patch("keboola_agent_cli.frozen_dist.shutil.which", _which_only()): + dist = detect_frozen_distribution() + assert dist is not None + assert dist.channel is FrozenChannel.SYSTEM + assert dist.upgrade_command is None + assert NATIVE_PACKAGE_NAME in dist.upgrade_hint + + +class TestNoChannelRecommendsPythonTooling: + """The whole point of the module, asserted directly. + + A frozen binary has no uv/pip tool environment behind it. If any channel + ever grows a uv/pip command, the original bug is back: the user installs a + second kbagent that shadows theirs on PATH while the real one stays stale. + """ + + @pytest.mark.parametrize("channel", list(FrozenChannel)) + def test_upgrade_command_is_never_a_python_installer(self, channel): + command = _UPGRADE_COMMANDS.get(channel, "") + assert "uv tool" not in command + assert "pip install" not in command + assert "uvx" not in command + + @pytest.mark.parametrize("channel", list(FrozenChannel)) + def test_every_channel_yields_an_actionable_hint(self, channel): + """No channel may leave the user without a next step.""" + from keboola_agent_cli.frozen_dist import _build_upgrade_hint + + hint = _build_upgrade_hint(channel, _UPGRADE_COMMANDS.get(channel)) + assert hint.strip() + assert "uv tool" not in hint diff --git a/tests/test_version_service.py b/tests/test_version_service.py index 3a70f15e..cd622377 100644 --- a/tests/test_version_service.py +++ b/tests/test_version_service.py @@ -7,6 +7,7 @@ import pytest from keboola_agent_cli.constants import MCP_UPGRADE_TIMEOUT +from keboola_agent_cli.frozen_dist import FrozenChannel, FrozenDistribution from keboola_agent_cli.services.version_service import ( MCP_BINARY_NAME, MCP_PACKAGE_NAME, @@ -26,6 +27,7 @@ _uv_tool_list_has_mcp, build_kbagent_upgrade_command, get_update_timeout, + prepare_kbagent_update_plan, resolve_kbagent_wheel_url, ) from keboola_agent_cli.update_runner import DeferredUpdateRequest, InstallRun, InstallStatus @@ -1570,3 +1572,230 @@ def test_a_real_failure_is_still_reported_as_one(self) -> None: self._kbagent(message="Update failed: locked"), self._mcp_up_to_date() ) assert "FAILED" in summary + + +class TestFrozenBuildSelfUpdateGuard: + """`kbagent update` / `kbagent version` on a native PyInstaller binary. + + Unlike the startup hook -- which was accidentally suppressed for frozen + builds by the bundled editable ``direct_url.json`` -- this path had NO guard + at all: ``build_kbagent_upgrade_command`` happily returned + ``uv tool install --force --reinstall`` inside a real frozen binary + (reproduced empirically). That installs a second, unrelated kbagent which + shadows the packaged one on PATH. + """ + + CHOCO_DIST = FrozenDistribution( + channel=FrozenChannel.CHOCOLATEY, + binary_path=r"C:\ProgramData\chocolatey\lib\keboola-cli2\tools\kbagent.exe", + upgrade_command="choco upgrade keboola-cli2", + upgrade_hint="upgrade it with: choco upgrade keboola-cli2", + ) + ARCHIVE_DIST = FrozenDistribution( + channel=FrozenChannel.ARCHIVE, + binary_path="/home/me/bin/kbagent", + upgrade_command=None, + upgrade_hint="re-download the signed archive from https://example.invalid/releases", + ) + + @staticmethod + def _patch_frozen(distribution): + return patch( + "keboola_agent_cli.services.version_service.detect_frozen_distribution", + return_value=distribution, + ) + + def test_plan_carries_channel_and_builds_no_install_command(self): + """No uv/pip command may be produced for a frozen build.""" + with self._patch_frozen(self.CHOCO_DIST): + plan = prepare_kbagent_update_plan("99.0.0") + assert plan.frozen_distribution is self.CHOCO_DIST + assert plan.command is None + # recovery_command is a uv command too -- it must not be offered either. + assert plan.recovery_command is None + assert plan.up_to_date is False + + def test_plan_skips_the_wheel_url_probe(self): + """The HEAD probe only exists to build a command we refuse to run.""" + with ( + self._patch_frozen(self.CHOCO_DIST), + patch( + "keboola_agent_cli.services.version_service.resolve_kbagent_wheel_url" + ) as mock_probe, + ): + prepare_kbagent_update_plan("99.0.0") + mock_probe.assert_not_called() + + def test_update_refuses_and_names_the_channel(self): + """`kbagent update` must install nothing at all on a frozen build. + + Asserted against BOTH install paths deliberately. Since issue #528 the + install no longer goes through ``subprocess.run`` here -- it goes + through ``run_install`` inline, or ``request_deferred_update`` on + Windows -- so a test that only watched ``subprocess.run`` would pass + vacuously while the deferred helper happily scheduled a `uv tool + install` over a Chocolatey binary. + """ + with self._patch_frozen(self.CHOCO_DIST): + plan = prepare_kbagent_update_plan("99.0.0") + with ( + patch("keboola_agent_cli.services.version_service.run_install") as mock_install, + patch( + "keboola_agent_cli.services.version_service.request_deferred_update" + ) as mock_defer, + patch( + "keboola_agent_cli.services.version_service.should_defer", return_value=True + ) as mock_should_defer, + patch("keboola_agent_cli.services.version_service.subprocess.run") as mock_run, + ): + result = VersionService._update_kbagent(plan) + mock_install.assert_not_called() + mock_defer.assert_not_called() + mock_run.assert_not_called() + # Returned before the platform even got a say -- should_defer() is + # forced True above precisely so a wrong branch order would show up. + mock_should_defer.assert_not_called() + assert result["updated"] is False + assert result["install_channel"] == "chocolatey" + assert result["upgrade_command"] == "choco upgrade keboola-cli2" + assert "choco upgrade keboola-cli2" in result["message"] + assert "uv tool install" not in result["message"] + + def test_update_of_archive_install_points_at_the_release_page(self): + """A channel with no single command still gets an actionable message.""" + with self._patch_frozen(self.ARCHIVE_DIST): + plan = prepare_kbagent_update_plan("99.0.0") + result = VersionService._update_kbagent(plan) + assert result["upgrade_command"] is None + assert "re-download the signed archive" in result["message"] + assert "uv tool install" not in result["message"] + + def test_up_to_date_frozen_binary_short_circuits_first(self): + """Being current still wins over the frozen branch.""" + plan = KbagentUpdatePlan( + current_version="1.0.0", + latest_version="1.0.0", + up_to_date=True, + command=None, + recovery_command=None, + frozen_distribution=self.CHOCO_DIST, + ) + result = VersionService._update_kbagent(plan) + assert result["up_to_date"] is True + assert "already up to date" in result["message"] + + def test_summary_does_not_report_a_refusal_as_a_failure(self): + """`updated=False` here means "not ours to do", not "it broke". + + Without a dedicated branch this falls through to the generic + "update FAILED" arm of _compose_update_summary. + """ + with self._patch_frozen(self.CHOCO_DIST): + plan = prepare_kbagent_update_plan("99.0.0") + kbagent_result = VersionService._update_kbagent(plan) + summary = VersionService._compose_update_summary(kbagent_result, {}) + assert "FAILED" not in summary + assert "choco upgrade keboola-cli2" in summary + assert "99.0.0" in summary + + def test_non_frozen_plan_still_builds_the_uv_command(self): + """Regression guard: the Python-distribution path is untouched.""" + with ( + self._patch_frozen(None), + patch( + "keboola_agent_cli.services.version_service.resolve_kbagent_wheel_url", + return_value=None, + ), + ): + plan = prepare_kbagent_update_plan("99.0.0") + assert plan.frozen_distribution is None + assert plan.command is not None + assert plan.recovery_command is not None + + +class TestFrozenBuildVersionOutput: + """`kbagent version` must advertise the channel command, not uv.""" + + @pytest.fixture(autouse=True) + def _no_real_probes(self): + with ( + patch( + "keboola_agent_cli.services.version_service._get_local_mcp_version", + return_value="1.46.0", + ), + patch( + "keboola_agent_cli.services.version_service._detect_mcp_install_method", + return_value="uv_tool", + ), + patch( + "keboola_agent_cli.services.version_service._fetch_mcp_latest_version", + return_value="1.46.0", + ), + patch( + "keboola_agent_cli.services.version_service._fetch_kbagent_latest_version", + return_value="99.0.0", + ), + patch("keboola_agent_cli.auto_update._write_cache"), + ): + yield + + def test_frozen_advertises_the_channel_command(self): + with patch( + "keboola_agent_cli.services.version_service.detect_frozen_distribution", + return_value=TestFrozenBuildSelfUpdateGuard.CHOCO_DIST, + ): + result = VersionService().get_versions() + kbagent = result["kbagent"] + assert kbagent["upgrade_command"] == "choco upgrade keboola-cli2" + assert kbagent["install_channel"] == "chocolatey" + assert "uv tool install" not in kbagent["upgrade_command"] + + def test_frozen_archive_keeps_prose_out_of_upgrade_command(self): + """`upgrade_command` must stay runnable-or-empty, never a sentence. + + The gotchas entry tells consumers they may shell out to + `upgrade_command`; handing them "re-download the signed archive + from https://..." would make them execute prose. Channels with no + single command carry it in `upgrade_hint` instead. + """ + with patch( + "keboola_agent_cli.services.version_service.detect_frozen_distribution", + return_value=TestFrozenBuildSelfUpdateGuard.ARCHIVE_DIST, + ): + result = VersionService().get_versions() + kbagent = result["kbagent"] + assert kbagent["upgrade_command"] == "" + assert "re-download the signed archive" in kbagent["upgrade_hint"] + assert kbagent["install_channel"] == "archive" + + def test_offline_refusal_does_not_print_vnone(self): + """The release lookup can fail; the summary must not say "-> vNone".""" + plan = KbagentUpdatePlan( + current_version="1.0.0", + latest_version=None, + up_to_date=None, + command=None, + recovery_command=None, + frozen_distribution=TestFrozenBuildSelfUpdateGuard.CHOCO_DIST, + ) + summary = VersionService._compose_update_summary(VersionService._update_kbagent(plan), {}) + assert "vNone" not in summary + assert "latest version unknown" in summary + assert "choco upgrade keboola-cli2" in summary + + def test_non_frozen_json_shape_is_unchanged(self): + """The additive key must not appear for uv/pip installs.""" + with ( + patch( + "keboola_agent_cli.services.version_service.detect_frozen_distribution", + return_value=None, + ), + patch( + "keboola_agent_cli.services.version_service.resolve_kbagent_wheel_url", + return_value=None, + ), + ): + result = VersionService().get_versions() + assert "install_channel" not in result["kbagent"] + assert "upgrade_hint" not in result["kbagent"] + assert "uv tool install" in result["kbagent"]["upgrade_command"] diff --git a/uv.lock b/uv.lock index b3578065..f207b4f7 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.78.0" +version = "0.79.0" source = { editable = "." } dependencies = [ { name = "croniter" },