-
Notifications
You must be signed in to change notification settings - Fork 0
Add interactive upgrade prompt to update check notice #232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3a07798
81c46dc
c2c6f09
214aa35
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Introduces a curl | sh one-liner in _INSTALL_SCRIPT_COMMAND, which fetches and executes remote code at runtime. Review for security implications before allowing execution. Details✨ AI Reasoning 🔧 How do I fix it? Reply |
||
| _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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Constructs shell invocation ['sh','-c', command] for pipeline commands, enabling execution of dynamically sourced shell content; ensure the command is trusted and reviewed. Details✨ AI Reasoning 🔧 How do I fix it? Reply |
||
| 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() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adds run_foreground() that executes arbitrary argv (including shell-executed pipeline commands). Confirm the invoked commands are safe and audited before running.
Show fix
Details
✨ AI Reasoning
The new run_foreground() wrapper calls subprocess.run(argv, check=False) with inherited stdio (as intended in description). This will execute the argv produced by _upgrade_argv, including a sh -c wrapper for a curl | sh pipeline. Although not obfuscation, it materially enables executing remote, dynamic code in the user's terminal. This change was added in the PR and increases risk of running unreviewed remote script content.
Reply
@AikidoSec feedback: [FEEDBACK]to get better review comments in the future.Reply
@AikidoSec ignore: [REASON]to ignore this issue.More info