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/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/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 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