Skip to content

Commit b2cb732

Browse files
authored
Merge branch 'main' into fix-speak-tts-keepalive-timeout
2 parents feaee39 + 91e3057 commit b2cb732

6 files changed

Lines changed: 263 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@ jobs:
157157
# hung download is killed and the next attempt retries, instead of wedging the cell
158158
# until it's cancelled. The shim lands in choco's bin dir (machine-wide, already on the
159159
# runner PATH), so the parent shell and later steps pick it up.
160+
#
161+
# During a sustained community.chocolatey.org outage the feed returns 503s *quickly*,
162+
# so every bounded attempt fails fast and the retry loop exhausts with no ffmpeg. Fall
163+
# back to a static build off GitHub's release CDN (a different, far more reliable origin)
164+
# and prepend its dir to GITHUB_PATH so later steps see it.
160165
- name: System deps (ffmpeg)
161166
shell: pwsh
162167
run: |
@@ -172,6 +177,17 @@ jobs:
172177
if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { break }
173178
Start-Sleep -Seconds 5
174179
}
180+
if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
181+
Write-Host "choco couldn't provide ffmpeg; downloading a static build from GitHub…"
182+
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
183+
$zip = Join-Path $env:RUNNER_TEMP "ffmpeg.zip"
184+
$dest = Join-Path $env:RUNNER_TEMP "ffmpeg"
185+
Invoke-WebRequest -Uri $url -OutFile $zip
186+
Expand-Archive -Path $zip -DestinationPath $dest -Force
187+
$bin = (Get-ChildItem -Path $dest -Recurse -Filter ffmpeg.exe | Select-Object -First 1).DirectoryName
188+
$env:PATH = "$bin;$env:PATH"
189+
Add-Content -Path $env:GITHUB_PATH -Value $bin
190+
}
175191
ffmpeg -version
176192
177193
- name: Install uv (cached)

REFERENCE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Product-scoped variables are `ASSEMBLYAI_*`; CLI-behavior variables are
3131
| `ASSEMBLYAI_API_KEY` | API key for all API calls; beats the keyring, loses to nothing but a `--api-key` validation flag. |
3232
| `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. |
3333
| `AAI_AUTH_PORT` | Loopback callback port for `assembly login` (dev/test only; default 8585). |
34-
| `AAI_NO_UPDATE_CHECK` | Disables the "update available" notice and its background refresh. |
34+
| `AAI_NO_UPDATE_CHECK` | Disables the "update available" notice, its interactive "update now?" prompt, and the background refresh. |
3535
| `AAI_TELEMETRY_DISABLED` / `DO_NOT_TRACK` | Disables anonymous usage telemetry (always beats the persisted choice). |
3636
| `NO_COLOR` / `FORCE_COLOR` | Standard color overrides; `--color always` / `--color never` sets them for child consoles too. |
3737
| `CI` | Suppresses interactive affordances (spinners, the update notice); never changes output shape. |

aai_cli/core/procs.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,13 @@ def spawn_detached(cli_args: list[str], *, disable_env_var: str) -> None:
2828
start_new_session=True,
2929
env={**os.environ, disable_env_var: "1"},
3030
)
31+
32+
33+
def run_foreground(argv: list[str]) -> int:
34+
"""Run ``argv`` to completion in the foreground and return its exit status.
35+
36+
The opposite of ``spawn_detached``: stdio is *inherited*, so the child's output
37+
streams straight to the terminal. Backs the interactive update prompt, where the
38+
user watches the brew/uv/curl installer run. S603 is ignored project-wide.
39+
"""
40+
return subprocess.run(argv, check=False).returncode

aai_cli/ui/update_check.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,29 @@
99

1010
from __future__ import annotations
1111

12+
import shlex
1213
import sys
1314
import time
1415

16+
import typer
1517
from packaging.version import InvalidVersion, Version
1618
from rich.console import Group
1719
from rich.panel import Panel
1820
from rich.text import Text
1921

2022
from aai_cli import __version__
21-
from aai_cli.core import config, env, procs
23+
from aai_cli.core import config, env, procs, stdio
2224
from aai_cli.core.errors import CLIError
2325
from aai_cli.ui import output
2426

2527
ENV_DISABLED = "AAI_NO_UPDATE_CHECK"
2628
_RELEASES_URL = "https://api.github.com/repos/AssemblyAI/cli/releases/latest"
2729
DOCS_URL = "https://github.com/AssemblyAI/cli#installation"
30+
_INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh"
31+
# Generic fallback when the install channel is unknown: the canonical one-liner
32+
# installer, which re-installs over any existing copy (it runs through a shell
33+
# because of the pipe — see ``_upgrade_argv``).
34+
_INSTALL_SCRIPT_COMMAND = f"curl -LsSf {_INSTALL_SCRIPT_URL} | sh"
2835
_CHECK_INTERVAL_SECONDS = 24 * 60 * 60
2936
_FETCH_TIMEOUT_SECONDS = 5.0
3037
_USER_AGENT = f"assembly-cli/{__version__}"
@@ -65,6 +72,24 @@ def detect_upgrade_command() -> str:
6572
)
6673

6774

75+
def resolve_upgrade_command() -> str:
76+
"""The command that upgrades the running install, always non-empty.
77+
78+
The detected channel command (brew/pipx/uv) when known, otherwise the canonical
79+
install-script one-liner — which works regardless of how the CLI was installed.
80+
"""
81+
return detect_upgrade_command() or _INSTALL_SCRIPT_COMMAND
82+
83+
84+
def _upgrade_argv(command: str) -> list[str]:
85+
"""The argv for running ``command``. The install-script fallback is a shell
86+
pipeline (``curl … | sh``) so it runs through ``sh -c``; the package-manager
87+
commands are plain argv split on whitespace."""
88+
if command == _INSTALL_SCRIPT_COMMAND:
89+
return ["sh", "-c", command]
90+
return shlex.split(command)
91+
92+
6893
def fetch_and_cache() -> None:
6994
"""Fetch the latest release tag from GitHub and cache it. Best-effort.
7095
@@ -128,6 +153,35 @@ def _render(current: str, latest: str) -> None:
128153
output.error_console.print(panel)
129154

130155

156+
def _confirm_upgrade() -> bool:
157+
"""Ask whether to upgrade now (interactive sessions only). Default is No, so a
158+
bare Enter declines; an aborted prompt (Ctrl-C / EOF) is treated as No too."""
159+
try:
160+
return typer.confirm("Update now?", default=False, err=True)
161+
except (typer.Abort, EOFError):
162+
return False
163+
164+
165+
def _report_upgrade(latest: str, command: str, returncode: int) -> None:
166+
if returncode == 0:
167+
msg = f"Updated to {latest}. Restart assembly to use it."
168+
output.error_console.print(output.success(msg))
169+
else:
170+
output.error_console.print(output.fail(f"Update failed — run '{command}' manually."))
171+
172+
173+
def _maybe_prompt_upgrade(latest: str) -> None:
174+
"""After the notice, offer to run the upgrade in place. Only when stdin is a real
175+
terminal, so a human can answer; a piped/redirected stdin is left untouched."""
176+
if not stdio.stdin_is_tty():
177+
return
178+
command = resolve_upgrade_command()
179+
if not _confirm_upgrade():
180+
return
181+
returncode = procs.run_foreground(_upgrade_argv(command))
182+
_report_upgrade(latest, command, returncode)
183+
184+
131185
def _cache_is_stale(last_check: float | None, *, now: float) -> bool:
132186
if last_check is None:
133187
return True
@@ -153,5 +207,6 @@ def _maybe_notify(*, json_mode: bool) -> None:
153207
now = time.time()
154208
if latest is not None and is_newer(latest, __version__):
155209
_render(__version__, latest)
210+
_maybe_prompt_upgrade(latest)
156211
if _cache_is_stale(last_check, now=now):
157212
spawn_refresh()

tests/conftest.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import pytest
77
from keyring.backend import KeyringBackend
88

9+
from aai_cli.ui import theme
10+
911
# Captured at import, before `isolate_env` strips ASSEMBLYAI_API_KEY from the
1012
# environment. The e2e suite uses this real key to drive the CLI as a subprocess;
1113
# unit tests still run fully isolated.
@@ -112,6 +114,24 @@ def pin_timezone(monkeypatch):
112114
time.tzset()
113115

114116

117+
@pytest.fixture(autouse=True)
118+
def _reset_theme_style_cache():
119+
# Rich caches each Style's rendered ANSI in Style._ansi on first render and does NOT
120+
# key that cache on the color system (rich/style.py: `_make_ansi_codes` fills
121+
# `self._ansi` once, and `render` returns it thereafter). The `theme.THEME` styles are
122+
# module globals shared by every console make_console builds, so whichever console
123+
# renders a given `aai.*` style *first* pins its color depth for the rest of the
124+
# process: a test that renders e.g. aai.error through a no-color/standard console
125+
# poisons the shared Style, and a later test asserting the *truecolor* ANSI
126+
# (test_setup_render / test_transcripts color tests) gets the stale 16-color downgrade.
127+
# That's an order-dependent flake pytest-randomly flips green/red by seed (and it bit
128+
# both Linux and the Windows matrix). Reset the per-Style cache before each test so
129+
# every test renders the theme from a pristine state. Same hermeticity rationale as the
130+
# rendering fixtures above; `_environ={}` alone can't fix it (the cache isn't env-keyed).
131+
for style in theme.THEME.styles.values():
132+
style._ansi = None
133+
134+
115135
@pytest.fixture(autouse=True)
116136
def fixed_render_size(monkeypatch):
117137
# Pin the render width/height for the *whole* suite so anything that renders

tests/test_update_prompt.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""The interactive "update now?" prompt that the startup notice offers."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
import time
7+
import types
8+
9+
from rich.console import Console
10+
11+
from aai_cli.core import config, procs, stdio
12+
from aai_cli.ui import output, theme, update_check
13+
14+
15+
def _tty_console() -> tuple[Console, io.StringIO]:
16+
# A theme-aware console reporting as a terminal, color env pinned for stable output
17+
# (mirrors the helper in test_update_check.py).
18+
buf = io.StringIO()
19+
return theme.make_console(file=buf, force_terminal=True, width=80, _environ={}), buf
20+
21+
22+
def test_resolve_upgrade_command_uses_detected_channel(monkeypatch):
23+
monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "brew upgrade assembly")
24+
assert update_check.resolve_upgrade_command() == "brew upgrade assembly"
25+
26+
27+
def test_resolve_upgrade_command_falls_back_to_install_script(monkeypatch):
28+
# Unknown install channel -> the canonical curl|sh installer, not an empty string.
29+
monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "")
30+
command = update_check.resolve_upgrade_command()
31+
assert command == update_check._INSTALL_SCRIPT_COMMAND
32+
assert "install.sh" in command
33+
34+
35+
def test_upgrade_argv_runs_install_script_through_a_shell():
36+
# The fallback is a pipeline (curl … | sh), so it must go through `sh -c`, not be
37+
# split into bare argv (which would hand `|` and `sh` to curl as arguments).
38+
argv = update_check._upgrade_argv(update_check._INSTALL_SCRIPT_COMMAND)
39+
assert argv == ["sh", "-c", update_check._INSTALL_SCRIPT_COMMAND]
40+
41+
42+
def test_upgrade_argv_splits_package_manager_command():
43+
assert update_check._upgrade_argv("brew upgrade assembly") == ["brew", "upgrade", "assembly"]
44+
45+
46+
def test_run_foreground_inherits_stdio_and_returns_status(monkeypatch):
47+
calls = {}
48+
49+
def fake_run(argv, *, check):
50+
calls["argv"] = argv
51+
calls["check"] = check
52+
return types.SimpleNamespace(returncode=7)
53+
54+
monkeypatch.setattr("aai_cli.core.procs.subprocess.run", fake_run)
55+
56+
assert procs.run_foreground(["brew", "upgrade", "assembly"]) == 7
57+
assert calls["argv"] == ["brew", "upgrade", "assembly"]
58+
assert calls["check"] is False # exit status is inspected, never raised
59+
60+
61+
def _enable_prompt(tmp_path, monkeypatch) -> io.StringIO:
62+
"""Cache a newer version, a tty stderr console, and an interactive stdin so the
63+
update notice renders and the upgrade prompt is reachable."""
64+
monkeypatch.setattr(config, "config_dir", lambda: tmp_path)
65+
config.set_update_cache(last_check=time.time(), latest_version="9.9.9")
66+
con, buf = _tty_console()
67+
monkeypatch.setattr(output, "error_console", con)
68+
monkeypatch.delenv("CI", raising=False)
69+
monkeypatch.delenv(update_check.ENV_DISABLED, raising=False)
70+
monkeypatch.setattr(stdio, "stdin_is_tty", lambda: True)
71+
return buf
72+
73+
74+
def test_prompt_runs_upgrade_when_confirmed(tmp_path, monkeypatch):
75+
buf = _enable_prompt(tmp_path, monkeypatch)
76+
monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "brew upgrade assembly")
77+
78+
confirm = {}
79+
80+
def fake_confirm(text, *, default, err):
81+
confirm["text"] = text
82+
confirm["default"] = default
83+
confirm["err"] = err
84+
return True
85+
86+
monkeypatch.setattr(update_check.typer, "confirm", fake_confirm)
87+
88+
ran = {}
89+
90+
def fake_run_foreground(argv):
91+
ran["argv"] = argv
92+
return 0
93+
94+
monkeypatch.setattr(procs, "run_foreground", fake_run_foreground)
95+
96+
update_check.maybe_notify(json_mode=False)
97+
98+
assert ran["argv"] == ["brew", "upgrade", "assembly"] # the detected channel ran
99+
assert "Update now?" in confirm["text"] # the prompt actually asks
100+
assert confirm["default"] is False # default-No: a bare Enter declines
101+
assert confirm["err"] is True # prompt rides stderr, like the notice
102+
out = buf.getvalue()
103+
assert "Updated to" in out
104+
assert "9.9.9" in out
105+
assert "Restart" in out # tells the user the new binary takes over next run
106+
107+
108+
def test_prompt_skips_upgrade_when_declined(tmp_path, monkeypatch):
109+
buf = _enable_prompt(tmp_path, monkeypatch)
110+
monkeypatch.setattr(update_check.typer, "confirm", lambda *a, **k: False)
111+
112+
ran = []
113+
114+
def fake_run_foreground(argv):
115+
ran.append(argv)
116+
return 0
117+
118+
monkeypatch.setattr(procs, "run_foreground", fake_run_foreground)
119+
120+
update_check.maybe_notify(json_mode=False)
121+
122+
assert ran == [] # declining runs nothing
123+
assert "Update available" in buf.getvalue() # the notice still showed
124+
125+
126+
def test_no_upgrade_prompt_when_stdin_not_a_tty(tmp_path, monkeypatch):
127+
buf = _enable_prompt(tmp_path, monkeypatch)
128+
monkeypatch.setattr(stdio, "stdin_is_tty", lambda: False) # piped/redirected stdin
129+
130+
asked = []
131+
monkeypatch.setattr(update_check.typer, "confirm", lambda *a, **k: asked.append(True))
132+
133+
update_check.maybe_notify(json_mode=False)
134+
135+
assert asked == [] # a non-interactive stdin is never prompted
136+
assert "Update available" in buf.getvalue() # but the notice still renders
137+
138+
139+
def test_prompt_reports_failure_when_upgrade_errors(tmp_path, monkeypatch):
140+
buf = _enable_prompt(tmp_path, monkeypatch)
141+
monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "brew upgrade assembly")
142+
monkeypatch.setattr(update_check.typer, "confirm", lambda *a, **k: True)
143+
monkeypatch.setattr(procs, "run_foreground", lambda argv: 3) # non-zero exit
144+
145+
update_check.maybe_notify(json_mode=False)
146+
147+
out = buf.getvalue()
148+
assert "Update failed" in out
149+
assert "brew upgrade assembly" in out # the command to re-run by hand
150+
151+
152+
def test_confirm_upgrade_treats_aborted_prompt_as_no(monkeypatch):
153+
# Ctrl-C (Abort) or Ctrl-D (EOFError) at the prompt must read as "no", never crash.
154+
for exc in (update_check.typer.Abort, EOFError):
155+
156+
def boom(*a, _exc=exc, **k):
157+
raise _exc()
158+
159+
monkeypatch.setattr(update_check.typer, "confirm", boom)
160+
assert update_check._confirm_upgrade() is False

0 commit comments

Comments
 (0)