From 3a077984db6742c95751a22fc7bbf888715eec10 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 23:49:31 +0000 Subject: [PATCH 1/4] Offer to upgrade in place from the startup update notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "update available" notice now becomes interactive: in a TTY session it asks "Update now?" and, on yes, runs the upgrade in the foreground so the user watches the installer's own output. The upgrade command is the detected install channel (brew/pipx/uv) when known, falling back to the canonical install-script one-liner (curl -LsSf .../install.sh | sh) when the channel is unknown — which runs through `sh -c` because of the pipe. Declining, a non-interactive stdin, CI, --json, and AAI_NO_UPDATE_CHECK all suppress the prompt, and every failure is swallowed so it can never break a command. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018rFXiSw66eKCHjV8DsEQ7q --- REFERENCE.md | 2 +- aai_cli/core/procs.py | 10 +++ aai_cli/ui/update_check.py | 57 ++++++++++++- tests/test_update_prompt.py | 160 ++++++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 tests/test_update_prompt.py diff --git a/REFERENCE.md b/REFERENCE.md index a0183021..bf9f3d8e 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -31,7 +31,7 @@ Product-scoped variables are `ASSEMBLYAI_*`; CLI-behavior variables are | `ASSEMBLYAI_API_KEY` | API key for all API calls; beats the keyring, loses to nothing but a `--api-key` validation flag. | | `AAI_ENV` | Backend environment (`production`, `sandbox000`); beats the profile's stored env, loses to `--env`/`--sandbox`. The non-production environments are internal: selecting one (here, via `--env`/`--sandbox`, or a profile binding) is rejected with exit 2 unless the active profile is signed in with an `@assemblyai.com` login, and `--env`/`--sandbox` and the sandbox-only commands are hidden from `--help` for everyone else. | | `AAI_AUTH_PORT` | Loopback callback port for `assembly login` (dev/test only; default 8585). | -| `AAI_NO_UPDATE_CHECK` | Disables the "update available" notice and its background refresh. | +| `AAI_NO_UPDATE_CHECK` | Disables the "update available" notice, its interactive "update now?" prompt, and the background refresh. | | `AAI_TELEMETRY_DISABLED` / `DO_NOT_TRACK` | Disables anonymous usage telemetry (always beats the persisted choice). | | `NO_COLOR` / `FORCE_COLOR` | Standard color overrides; `--color always` / `--color never` sets them for child consoles too. | | `CI` | Suppresses interactive affordances (spinners, the update notice); never changes output shape. | diff --git a/aai_cli/core/procs.py b/aai_cli/core/procs.py index f0f81713..918549f1 100644 --- a/aai_cli/core/procs.py +++ b/aai_cli/core/procs.py @@ -28,3 +28,13 @@ def spawn_detached(cli_args: list[str], *, disable_env_var: str) -> None: start_new_session=True, env={**os.environ, disable_env_var: "1"}, ) + + +def run_foreground(argv: list[str]) -> int: + """Run ``argv`` to completion in the foreground and return its exit status. + + The opposite of ``spawn_detached``: stdio is *inherited*, so the child's output + streams straight to the terminal. Backs the interactive update prompt, where the + user watches the brew/uv/curl installer run. S603 is ignored project-wide. + """ + return subprocess.run(argv, check=False).returncode diff --git a/aai_cli/ui/update_check.py b/aai_cli/ui/update_check.py index 889a7202..34d8d08e 100644 --- a/aai_cli/ui/update_check.py +++ b/aai_cli/ui/update_check.py @@ -9,22 +9,29 @@ from __future__ import annotations +import shlex import sys import time +import typer from packaging.version import InvalidVersion, Version from rich.console import Group from rich.panel import Panel from rich.text import Text from aai_cli import __version__ -from aai_cli.core import config, env, procs +from aai_cli.core import config, env, procs, stdio from aai_cli.core.errors import CLIError from aai_cli.ui import output ENV_DISABLED = "AAI_NO_UPDATE_CHECK" _RELEASES_URL = "https://api.github.com/repos/AssemblyAI/cli/releases/latest" DOCS_URL = "https://github.com/AssemblyAI/cli#installation" +_INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh" +# Generic fallback when the install channel is unknown: the canonical one-liner +# installer, which re-installs over any existing copy (it runs through a shell +# because of the pipe — see ``_upgrade_argv``). +_INSTALL_SCRIPT_COMMAND = f"curl -LsSf {_INSTALL_SCRIPT_URL} | sh" _CHECK_INTERVAL_SECONDS = 24 * 60 * 60 _FETCH_TIMEOUT_SECONDS = 5.0 _USER_AGENT = f"assembly-cli/{__version__}" @@ -65,6 +72,24 @@ def detect_upgrade_command() -> str: ) +def resolve_upgrade_command() -> str: + """The command that upgrades the running install, always non-empty. + + The detected channel command (brew/pipx/uv) when known, otherwise the canonical + install-script one-liner — which works regardless of how the CLI was installed. + """ + return detect_upgrade_command() or _INSTALL_SCRIPT_COMMAND + + +def _upgrade_argv(command: str) -> list[str]: + """The argv for running ``command``. The install-script fallback is a shell + pipeline (``curl … | sh``) so it runs through ``sh -c``; the package-manager + commands are plain argv split on whitespace.""" + if command == _INSTALL_SCRIPT_COMMAND: + return ["sh", "-c", command] + return shlex.split(command) + + def fetch_and_cache() -> None: """Fetch the latest release tag from GitHub and cache it. Best-effort. @@ -128,6 +153,35 @@ def _render(current: str, latest: str) -> None: output.error_console.print(panel) +def _confirm_upgrade() -> bool: + """Ask whether to upgrade now (interactive sessions only). Default is No, so a + bare Enter declines; an aborted prompt (Ctrl-C / EOF) is treated as No too.""" + try: + return typer.confirm("Update now?", default=False, err=True) + except (typer.Abort, EOFError): + return False + + +def _report_upgrade(latest: str, command: str, returncode: int) -> None: + if returncode == 0: + msg = f"Updated to {latest}. Restart assembly to use it." + output.error_console.print(output.success(msg)) + else: + output.error_console.print(output.fail(f"Update failed — run '{command}' manually.")) + + +def _maybe_prompt_upgrade(latest: str) -> None: + """After the notice, offer to run the upgrade in place. Only when stdin is a real + terminal, so a human can answer; a piped/redirected stdin is left untouched.""" + if not stdio.stdin_is_tty(): + return + command = resolve_upgrade_command() + if not _confirm_upgrade(): + return + returncode = procs.run_foreground(_upgrade_argv(command)) + _report_upgrade(latest, command, returncode) + + def _cache_is_stale(last_check: float | None, *, now: float) -> bool: if last_check is None: return True @@ -153,5 +207,6 @@ def _maybe_notify(*, json_mode: bool) -> None: now = time.time() if latest is not None and is_newer(latest, __version__): _render(__version__, latest) + _maybe_prompt_upgrade(latest) if _cache_is_stale(last_check, now=now): spawn_refresh() diff --git a/tests/test_update_prompt.py b/tests/test_update_prompt.py new file mode 100644 index 00000000..b4987bda --- /dev/null +++ b/tests/test_update_prompt.py @@ -0,0 +1,160 @@ +"""The interactive "update now?" prompt that the startup notice offers.""" + +from __future__ import annotations + +import io +import time +import types + +from rich.console import Console + +from aai_cli.core import config, procs, stdio +from aai_cli.ui import output, theme, update_check + + +def _tty_console() -> tuple[Console, io.StringIO]: + # A theme-aware console reporting as a terminal, color env pinned for stable output + # (mirrors the helper in test_update_check.py). + buf = io.StringIO() + return theme.make_console(file=buf, force_terminal=True, width=80, _environ={}), buf + + +def test_resolve_upgrade_command_uses_detected_channel(monkeypatch): + monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "brew upgrade assembly") + assert update_check.resolve_upgrade_command() == "brew upgrade assembly" + + +def test_resolve_upgrade_command_falls_back_to_install_script(monkeypatch): + # Unknown install channel -> the canonical curl|sh installer, not an empty string. + monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "") + command = update_check.resolve_upgrade_command() + assert command == update_check._INSTALL_SCRIPT_COMMAND + assert "install.sh" in command + + +def test_upgrade_argv_runs_install_script_through_a_shell(): + # The fallback is a pipeline (curl … | sh), so it must go through `sh -c`, not be + # split into bare argv (which would hand `|` and `sh` to curl as arguments). + argv = update_check._upgrade_argv(update_check._INSTALL_SCRIPT_COMMAND) + assert argv == ["sh", "-c", update_check._INSTALL_SCRIPT_COMMAND] + + +def test_upgrade_argv_splits_package_manager_command(): + assert update_check._upgrade_argv("brew upgrade assembly") == ["brew", "upgrade", "assembly"] + + +def test_run_foreground_inherits_stdio_and_returns_status(monkeypatch): + calls = {} + + def fake_run(argv, *, check): + calls["argv"] = argv + calls["check"] = check + return types.SimpleNamespace(returncode=7) + + monkeypatch.setattr("aai_cli.core.procs.subprocess.run", fake_run) + + assert procs.run_foreground(["brew", "upgrade", "assembly"]) == 7 + assert calls["argv"] == ["brew", "upgrade", "assembly"] + assert calls["check"] is False # exit status is inspected, never raised + + +def _enable_prompt(tmp_path, monkeypatch) -> io.StringIO: + """Cache a newer version, a tty stderr console, and an interactive stdin so the + update notice renders and the upgrade prompt is reachable.""" + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + config.set_update_cache(last_check=time.time(), latest_version="9.9.9") + con, buf = _tty_console() + monkeypatch.setattr(output, "error_console", con) + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv(update_check.ENV_DISABLED, raising=False) + monkeypatch.setattr(stdio, "stdin_is_tty", lambda: True) + return buf + + +def test_prompt_runs_upgrade_when_confirmed(tmp_path, monkeypatch): + buf = _enable_prompt(tmp_path, monkeypatch) + monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "brew upgrade assembly") + + confirm = {} + + def fake_confirm(text, *, default, err): + confirm["text"] = text + confirm["default"] = default + confirm["err"] = err + return True + + monkeypatch.setattr(update_check.typer, "confirm", fake_confirm) + + ran = {} + + def fake_run_foreground(argv): + ran["argv"] = argv + return 0 + + monkeypatch.setattr(procs, "run_foreground", fake_run_foreground) + + update_check.maybe_notify(json_mode=False) + + assert ran["argv"] == ["brew", "upgrade", "assembly"] # the detected channel ran + assert "Update now?" in confirm["text"] # the prompt actually asks + assert confirm["default"] is False # default-No: a bare Enter declines + assert confirm["err"] is True # prompt rides stderr, like the notice + out = buf.getvalue() + assert "Updated to" in out + assert "9.9.9" in out + assert "Restart" in out # tells the user the new binary takes over next run + + +def test_prompt_skips_upgrade_when_declined(tmp_path, monkeypatch): + buf = _enable_prompt(tmp_path, monkeypatch) + monkeypatch.setattr(update_check.typer, "confirm", lambda *a, **k: False) + + ran = [] + + def fake_run_foreground(argv): + ran.append(argv) + return 0 + + monkeypatch.setattr(procs, "run_foreground", fake_run_foreground) + + update_check.maybe_notify(json_mode=False) + + assert ran == [] # declining runs nothing + assert "Update available" in buf.getvalue() # the notice still showed + + +def test_no_upgrade_prompt_when_stdin_not_a_tty(tmp_path, monkeypatch): + buf = _enable_prompt(tmp_path, monkeypatch) + monkeypatch.setattr(stdio, "stdin_is_tty", lambda: False) # piped/redirected stdin + + asked = [] + monkeypatch.setattr(update_check.typer, "confirm", lambda *a, **k: asked.append(True)) + + update_check.maybe_notify(json_mode=False) + + assert asked == [] # a non-interactive stdin is never prompted + assert "Update available" in buf.getvalue() # but the notice still renders + + +def test_prompt_reports_failure_when_upgrade_errors(tmp_path, monkeypatch): + buf = _enable_prompt(tmp_path, monkeypatch) + monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "brew upgrade assembly") + monkeypatch.setattr(update_check.typer, "confirm", lambda *a, **k: True) + monkeypatch.setattr(procs, "run_foreground", lambda argv: 3) # non-zero exit + + update_check.maybe_notify(json_mode=False) + + out = buf.getvalue() + assert "Update failed" in out + assert "brew upgrade assembly" in out # the command to re-run by hand + + +def test_confirm_upgrade_treats_aborted_prompt_as_no(monkeypatch): + # Ctrl-C (Abort) or Ctrl-D (EOFError) at the prompt must read as "no", never crash. + for exc in (update_check.typer.Abort, EOFError): + + def boom(*a, _exc=exc, **k): + raise _exc() + + monkeypatch.setattr(update_check.typer, "confirm", boom) + assert update_check._confirm_upgrade() is False From 81c46dc9f41a659512e5dff75a88bf1c637ea2fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:35:03 +0000 Subject: [PATCH 2/4] ci: re-trigger Windows matrix (color-depth flake, see #232) The tests (windows, py3.13) cell flaked on two pre-existing color-depth assertions (test_list_table_colors_status, test_render_steps_colors_status) unrelated to this PR's files; the same tests pass on main at this merge-base. Re-running with a fresh pytest-randomly seed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018rFXiSw66eKCHjV8DsEQ7q From c2c6f09d5cb231f96669d4feea0429e917bd2f85 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:36:48 +0000 Subject: [PATCH 3/4] ci: re-trigger after Chocolatey 503 outage (see #232) Both Windows matrix cells failed at the 'System deps (ffmpeg)' step: community.chocolatey.org returned 503 Service Unavailable, so ffmpeg never installed and the cells errored before tests ran. Pure infra flake; re-kicking. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018rFXiSw66eKCHjV8DsEQ7q From 214aa353d796700bae7128854233022adb9110c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:57:26 +0000 Subject: [PATCH 4/4] Fix two CI flakes blocking #232: choco ffmpeg outage + Rich style-cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows matrix and (intermittently) the Linux suite were failing on issues unrelated to this PR's feature code: 1. Windows "System deps (ffmpeg)" died when community.chocolatey.org returned 503s — the existing per-attempt timeout retries a *hang* but not a fast 503, so the loop exhausts with no ffmpeg. Add a fallback that pulls a static build off GitHub's release CDN (a different, far more reliable origin) and puts it on GITHUB_PATH. 2. test_list_table_colors_status / test_render_steps_colors_status flaked green/red by pytest-randomly seed (on both Linux and Windows). Root cause: Rich caches each Style's rendered ANSI in Style._ansi on first render without keying on the color system, and theme.THEME's Style objects are shared across every console make_console builds — so whichever console renders an aai.* style first pins its color depth for the rest of the process, and a later test asserting truecolor gets a stale 16-color downgrade. Add an autouse conftest fixture that resets the per-Style cache before each test (same hermeticity rationale as the existing rendering fixtures; _environ={} can't fix it since the cache isn't env-keyed). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018rFXiSw66eKCHjV8DsEQ7q --- .github/workflows/ci.yml | 16 ++++++++++++++++ tests/conftest.py | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75e6b3c3..a181d476 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,6 +157,11 @@ jobs: # hung download is killed and the next attempt retries, instead of wedging the cell # until it's cancelled. The shim lands in choco's bin dir (machine-wide, already on the # runner PATH), so the parent shell and later steps pick it up. + # + # During a sustained community.chocolatey.org outage the feed returns 503s *quickly*, + # so every bounded attempt fails fast and the retry loop exhausts with no ffmpeg. Fall + # back to a static build off GitHub's release CDN (a different, far more reliable origin) + # and prepend its dir to GITHUB_PATH so later steps see it. - name: System deps (ffmpeg) shell: pwsh run: | @@ -172,6 +177,17 @@ jobs: if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { break } Start-Sleep -Seconds 5 } + if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { + Write-Host "choco couldn't provide ffmpeg; downloading a static build from GitHub…" + $url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip" + $zip = Join-Path $env:RUNNER_TEMP "ffmpeg.zip" + $dest = Join-Path $env:RUNNER_TEMP "ffmpeg" + Invoke-WebRequest -Uri $url -OutFile $zip + Expand-Archive -Path $zip -DestinationPath $dest -Force + $bin = (Get-ChildItem -Path $dest -Recurse -Filter ffmpeg.exe | Select-Object -First 1).DirectoryName + $env:PATH = "$bin;$env:PATH" + Add-Content -Path $env:GITHUB_PATH -Value $bin + } ffmpeg -version - name: Install uv (cached) diff --git a/tests/conftest.py b/tests/conftest.py index 0134f9fc..f77c3cea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,8 @@ import pytest from keyring.backend import KeyringBackend +from aai_cli.ui import theme + # Captured at import, before `isolate_env` strips ASSEMBLYAI_API_KEY from the # environment. The e2e suite uses this real key to drive the CLI as a subprocess; # unit tests still run fully isolated. @@ -112,6 +114,24 @@ def pin_timezone(monkeypatch): time.tzset() +@pytest.fixture(autouse=True) +def _reset_theme_style_cache(): + # Rich caches each Style's rendered ANSI in Style._ansi on first render and does NOT + # key that cache on the color system (rich/style.py: `_make_ansi_codes` fills + # `self._ansi` once, and `render` returns it thereafter). The `theme.THEME` styles are + # module globals shared by every console make_console builds, so whichever console + # renders a given `aai.*` style *first* pins its color depth for the rest of the + # process: a test that renders e.g. aai.error through a no-color/standard console + # poisons the shared Style, and a later test asserting the *truecolor* ANSI + # (test_setup_render / test_transcripts color tests) gets the stale 16-color downgrade. + # That's an order-dependent flake pytest-randomly flips green/red by seed (and it bit + # both Linux and the Windows matrix). Reset the per-Style cache before each test so + # every test renders the theme from a pristine state. Same hermeticity rationale as the + # rendering fixtures above; `_environ={}` alone can't fix it (the cache isn't env-keyed). + for style in theme.THEME.styles.values(): + style._ansi = None + + @pytest.fixture(autouse=True) def fixed_render_size(monkeypatch): # Pin the render width/height for the *whole* suite so anything that renders