diff --git a/pyproject.toml b/pyproject.toml index 2cad65fab..bd2428885 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.0.2" +version = "0.0.3" description = "Setup tool for Specify spec-driven development projects" requires-python = ">=3.11" dependencies = [ @@ -9,6 +9,7 @@ dependencies = [ "httpx", "platformdirs", "readchar", + "truststore>=0.10.4", ] [project.scripts] @@ -19,4 +20,4 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/specify_cli"] \ No newline at end of file +packages = ["src/specify_cli"] diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 94ac3deab..704d354d9 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -46,6 +46,11 @@ # For cross-platform keyboard input import readchar +import ssl +import truststore + +ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) +client = httpx.Client(verify=ssl_context) # Constants AI_CHOICES = { @@ -385,19 +390,18 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> bool: os.chdir(original_cwd) -def download_template_from_github(ai_assistant: str, download_dir: Path, *, verbose: bool = True, show_progress: bool = True): - """Download the latest template release from GitHub using HTTP requests. - Returns (zip_path, metadata_dict) - """ +def download_template_from_github(ai_assistant: str, download_dir: Path, *, verbose: bool = True, show_progress: bool = True, client: httpx.Client = None): repo_owner = "github" repo_name = "spec-kit" + if client is None: + client = httpx.Client(verify=ssl_context) if verbose: console.print("[cyan]Fetching latest release information...[/cyan]") api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" try: - response = httpx.get(api_url, timeout=30, follow_redirects=True) + response = client.get(api_url, timeout=30, follow_redirects=True) response.raise_for_status() release_data = response.json() except httpx.RequestError as e: @@ -437,18 +441,15 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, verb console.print(f"[cyan]Downloading template...[/cyan]") try: - with httpx.stream("GET", download_url, timeout=30, follow_redirects=True) as response: + with client.stream("GET", download_url, timeout=30, follow_redirects=True) as response: response.raise_for_status() total_size = int(response.headers.get('content-length', 0)) - with open(zip_path, 'wb') as f: if total_size == 0: - # No content-length header, download without progress for chunk in response.iter_bytes(chunk_size=8192): f.write(chunk) else: if show_progress: - # Show progress bar with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), @@ -462,10 +463,8 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, verb downloaded += len(chunk) progress.update(task, completed=downloaded) else: - # Silent download loop for chunk in response.iter_bytes(chunk_size=8192): f.write(chunk) - except httpx.RequestError as e: if verbose: console.print(f"[red]Error downloading template:[/red] {e}") @@ -483,7 +482,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, verb return zip_path, metadata -def download_and_extract_template(project_path: Path, ai_assistant: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None) -> Path: +def download_and_extract_template(project_path: Path, ai_assistant: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None) -> Path: """Download the latest release and extract it to create a new project. Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup) """ @@ -497,12 +496,13 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, is_curr ai_assistant, current_dir, verbose=verbose and tracker is None, - show_progress=(tracker is None) + show_progress=(tracker is None), + client=client ) if tracker: tracker.complete("fetch", f"release {meta['release']} ({meta['size']:,} bytes)") tracker.add("download", "Download template") - tracker.complete("download", meta['filename']) # already downloaded inside helper + tracker.complete("download", meta['filename']) except Exception as e: if tracker: tracker.error("fetch", str(e)) @@ -642,6 +642,7 @@ def init( ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), + skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), ): """ Initialize a new Specify project from the latest template. @@ -770,7 +771,12 @@ def init( with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: - download_and_extract_template(project_path, selected_ai, here, verbose=False, tracker=tracker) + # Create a httpx client with verify based on skip_tls + verify = not skip_tls + local_ssl_context = ssl_context if verify else False + local_client = httpx.Client(verify=local_ssl_context) + + download_and_extract_template(project_path, selected_ai, here, verbose=False, tracker=tracker, client=local_client) # Git step if not no_git: @@ -835,21 +841,25 @@ def init( # Removed farewell line per user request +# Add skip_tls option to check @app.command() -def check(): +def check(skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)")): """Check that all required tools are installed.""" show_banner() console.print("[bold]Checking Specify requirements...[/bold]\n") - + # Check if we have internet connectivity by trying to reach GitHub API console.print("[cyan]Checking internet connectivity...[/cyan]") + verify = not skip_tls + local_ssl_context = ssl_context if verify else False + local_client = httpx.Client(verify=local_ssl_context) try: - response = httpx.get("https://api.github.com", timeout=5, follow_redirects=True) + response = local_client.get("https://api.github.com", timeout=5, follow_redirects=True) console.print("[green]✓[/green] Internet connection available") except httpx.RequestError: console.print("[red]✗[/red] No internet connection - required for downloading templates") console.print("[yellow]Please check your internet connection[/yellow]") - + console.print("\n[cyan]Optional tools:[/cyan]") git_ok = check_tool("git", "https://git-scm.com/downloads")