Skip to content

Commit 089feca

Browse files
mnriemCopilot
andauthored
fix: move URL install confirmation prompt before spinner (#2783) (#2784)
* fix: move URL install confirmation prompt before spinner (#2783) The typer.confirm() prompt inside console.status() was overwritten by Rich's spinner animation, making extension add --from <url> appear hung. Move URL validation and the default-deny confirmation prompt before the spinner block so the user can see and respond to the [y/N] prompt. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: guard prompt with not dev, escape from_url in Rich markup Address PR review feedback: - Gate URL confirmation prompt on 'not dev' so --dev + --from does not show a confusing prompt for a URL path that will be ignored. - Escape from_url with rich.markup.escape() in both the warning panel and the download message to prevent markup injection via crafted URLs. * fix: remove unused import, reuse safe_url, add regression tests Address second round of PR review: - Remove unused urllib.request import from URL install path - Remove redundant re-import of rich.markup.escape; reuse safe_url computed before the spinner for download and error messages - Add test_add_from_url_prompts_before_spinner: asserts typer.confirm fires before console.status spinner to prevent #2783 regression - Add test_add_from_url_cancel_exits_cleanly: asserts declining the prompt exits with code 0 and prints Cancelled --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 3617cd9 commit 089feca

2 files changed

Lines changed: 100 additions & 30 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3082,6 +3082,43 @@ def extension_add(
30823082
manager = ExtensionManager(project_root)
30833083
speckit_version = get_speckit_version()
30843084

3085+
# Prompt for URL-based installs BEFORE the spinner so the user can
3086+
# actually see and respond to the confirmation (the Rich status
3087+
# spinner overwrites the typer.confirm prompt line, making it appear
3088+
# as though the command is hung).
3089+
# Guard with ``not dev`` so that --dev + --from does not show a
3090+
# confusing confirmation for a URL that will be ignored.
3091+
if from_url and not dev:
3092+
from urllib.parse import urlparse
3093+
from rich.markup import escape as _escape_markup
3094+
3095+
parsed = urlparse(from_url)
3096+
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
3097+
3098+
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
3099+
console.print("[red]Error:[/red] URL must use HTTPS for security.")
3100+
console.print("HTTP is only allowed for localhost URLs.")
3101+
raise typer.Exit(1)
3102+
3103+
safe_url = _escape_markup(from_url)
3104+
3105+
# Warn about untrusted sources — default-deny confirmation
3106+
console.print()
3107+
console.print(Panel(
3108+
f"[bold]You are installing an extension from an external URL that is not\n"
3109+
f"listed in any of your configured extension catalogs.[/bold]\n\n"
3110+
f"URL: {safe_url}\n\n"
3111+
f"Only install extensions from sources you trust.",
3112+
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
3113+
border_style="yellow",
3114+
padding=(1, 2),
3115+
))
3116+
console.print()
3117+
confirm = typer.confirm("Continue with installation?", default=False)
3118+
if not confirm:
3119+
console.print("Cancelled")
3120+
raise typer.Exit(0)
3121+
30853122
try:
30863123
with console.status(f"[cyan]Installing extension: {extension}[/cyan]"):
30873124
if dev:
@@ -3104,37 +3141,9 @@ def extension_add(
31043141

31053142
elif from_url:
31063143
# Install from URL (ZIP file)
3107-
import urllib.request
31083144
import urllib.error
3109-
from urllib.parse import urlparse
3110-
3111-
# Validate URL
3112-
parsed = urlparse(from_url)
3113-
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
3114-
3115-
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
3116-
console.print("[red]Error:[/red] URL must use HTTPS for security.")
3117-
console.print("HTTP is only allowed for localhost URLs.")
3118-
raise typer.Exit(1)
3119-
3120-
# Warn about untrusted sources — default-deny confirmation
3121-
console.print()
3122-
console.print(Panel(
3123-
f"[bold]You are installing an extension from an external URL that is not\n"
3124-
f"listed in any of your configured extension catalogs.[/bold]\n\n"
3125-
f"URL: {from_url}\n\n"
3126-
f"Only install extensions from sources you trust.",
3127-
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
3128-
border_style="yellow",
3129-
padding=(1, 2),
3130-
))
3131-
console.print()
3132-
confirm = typer.confirm("Continue with installation?", default=False)
3133-
if not confirm:
3134-
console.print("Cancelled")
3135-
raise typer.Exit(0)
31363145

3137-
console.print(f"Downloading from {from_url}...")
3146+
console.print(f"Downloading from {safe_url}...")
31383147

31393148
# Download ZIP to temp location
31403149
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
@@ -3151,7 +3160,7 @@ def extension_add(
31513160
# Install from downloaded ZIP
31523161
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
31533162
except urllib.error.URLError as e:
3154-
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
3163+
console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}")
31553164
raise typer.Exit(1)
31563165
finally:
31573166
# Clean up downloaded ZIP

tests/test_extensions.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3807,6 +3807,67 @@ def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path):
38073807
assert "bundled with spec-kit" in result.output
38083808
assert "reinstall" in result.output.lower()
38093809

3810+
def test_add_from_url_prompts_before_spinner(self, tmp_path):
3811+
"""Confirm prompt for --from <url> must fire before the console.status spinner.
3812+
3813+
Regression test for #2783: typer.confirm() inside console.status()
3814+
was overwritten by the Rich spinner, making the command appear hung.
3815+
"""
3816+
from typer.testing import CliRunner
3817+
from unittest.mock import patch, MagicMock
3818+
from specify_cli import app
3819+
3820+
project_dir = tmp_path / "test-project"
3821+
project_dir.mkdir()
3822+
(project_dir / ".specify").mkdir()
3823+
3824+
call_order: list[str] = []
3825+
3826+
original_status = MagicMock()
3827+
3828+
def record_status(*args, **kwargs):
3829+
call_order.append("spinner")
3830+
return original_status
3831+
3832+
runner = CliRunner()
3833+
with patch.object(Path, "cwd", return_value=project_dir), \
3834+
patch("specify_cli.console.status", side_effect=record_status), \
3835+
patch("typer.confirm", side_effect=lambda *a, **kw: (call_order.append("confirm"), False)[-1]):
3836+
result = runner.invoke(
3837+
app,
3838+
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
3839+
catch_exceptions=True,
3840+
)
3841+
3842+
assert "confirm" in call_order, "confirm prompt was never called"
3843+
# The confirm must fire BEFORE the spinner is entered
3844+
if "spinner" in call_order:
3845+
assert call_order.index("confirm") < call_order.index("spinner"), \
3846+
f"confirm must precede spinner, got: {call_order}"
3847+
assert result.exit_code == 0 # user declined → clean exit
3848+
3849+
def test_add_from_url_cancel_exits_cleanly(self, tmp_path):
3850+
"""Declining the --from <url> confirmation should exit with code 0."""
3851+
from typer.testing import CliRunner
3852+
from unittest.mock import patch
3853+
from specify_cli import app
3854+
3855+
project_dir = tmp_path / "test-project"
3856+
project_dir.mkdir()
3857+
(project_dir / ".specify").mkdir()
3858+
3859+
runner = CliRunner()
3860+
with patch.object(Path, "cwd", return_value=project_dir), \
3861+
patch("typer.confirm", return_value=False):
3862+
result = runner.invoke(
3863+
app,
3864+
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
3865+
catch_exceptions=True,
3866+
)
3867+
3868+
assert result.exit_code == 0
3869+
assert "Cancelled" in result.output
3870+
38103871

38113872
class TestDownloadExtensionBundled:
38123873
"""Tests for download_extension handling of bundled extensions."""

0 commit comments

Comments
 (0)