diff --git a/dropkit/main.py b/dropkit/main.py index 12ac7e4..60717ef 100644 --- a/dropkit/main.py +++ b/dropkit/main.py @@ -2,7 +2,9 @@ import json import re +import shutil import subprocess +import sys import time from pathlib import Path from typing import Any @@ -735,6 +737,26 @@ def wait_for_cloud_init(ssh_hostname: str, verbose: bool = False) -> tuple[bool, # Tailscale helper functions +_MACOS_TAILSCALE_PATH = "/Applications/Tailscale.app/Contents/MacOS/Tailscale" + + +def find_tailscale_cli() -> str | None: + """ + Find the Tailscale CLI binary. + + Checks PATH first, then falls back to the macOS App Store location + where Tailscale is installed as a GUI app without a symlink in PATH. + + Returns: + Path to the Tailscale CLI binary, or None if not found + """ + path = shutil.which("tailscale") + if path is not None: + return path + if sys.platform == "darwin" and Path(_MACOS_TAILSCALE_PATH).exists(): + return _MACOS_TAILSCALE_PATH + return None + def check_local_tailscale() -> bool: """ @@ -743,18 +765,20 @@ def check_local_tailscale() -> bool: Returns: True if Tailscale is running and the user is connected to a tailnet """ + tailscale_bin = find_tailscale_cli() + if tailscale_bin is None: + return False + try: result = subprocess.run( - ["tailscale", "status", "--json"], + [tailscale_bin, "status", "--json"], capture_output=True, timeout=5, ) if result.returncode != 0: return False - # Parse JSON to check if we're connected status = json.loads(result.stdout.decode("utf-8", errors="ignore")) - # BackendState should be "Running" if connected return status.get("BackendState") == "Running" except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError): return False @@ -1475,9 +1499,12 @@ def setup_tailscale( "[yellow]⚠[/yellow] SSH verification failed - you may need to wait a moment" ) else: - console.print( - "[yellow]⚠[/yellow] Local Tailscale not running - skipping firewall lockdown" + reason = ( + "not running — skipping firewall lockdown" + if find_tailscale_cli() + else "not found — install it or add it to PATH" ) + console.print(f"[yellow]⚠[/yellow] Tailscale {reason}") console.print( "[dim]Public SSH access remains available. Start Tailscale locally and run:[/dim]" ) diff --git a/tests/test_tailscale.py b/tests/test_tailscale.py index b785dd0..2ee76a2 100644 --- a/tests/test_tailscale.py +++ b/tests/test_tailscale.py @@ -9,6 +9,7 @@ from dropkit.main import ( check_local_tailscale, check_tailscale_installed, + find_tailscale_cli, install_tailscale_on_droplet, is_tailscale_ip, lock_down_to_tailscale, @@ -82,11 +83,48 @@ def test_invalid_ip_none_type(self): assert is_tailscale_ip(None) is False # type: ignore +class TestFindTailscaleCli: + """Tests for find_tailscale_cli function.""" + + @patch("dropkit.main.shutil.which", return_value="/usr/bin/tailscale") + def test_found_in_path(self, mock_which): + """Test when tailscale is found in PATH.""" + assert find_tailscale_cli() == "/usr/bin/tailscale" + + @patch("dropkit.main.Path.exists", return_value=True) + @patch("dropkit.main.sys.platform", "darwin") + @patch("dropkit.main.shutil.which", return_value=None) + def test_macos_app_store_fallback(self, mock_which, mock_exists): + """Test fallback to macOS App Store location.""" + assert find_tailscale_cli() == "/Applications/Tailscale.app/Contents/MacOS/Tailscale" + + @patch("dropkit.main.Path.exists", return_value=False) + @patch("dropkit.main.sys.platform", "darwin") + @patch("dropkit.main.shutil.which", return_value=None) + def test_macos_app_not_installed(self, mock_which, mock_exists): + """Test when Tailscale is not installed on macOS.""" + assert find_tailscale_cli() is None + + @patch("dropkit.main.sys.platform", "linux") + @patch("dropkit.main.shutil.which", return_value=None) + def test_linux_not_in_path(self, mock_which): + """Test when tailscale is not in PATH on Linux (no macOS fallback).""" + assert find_tailscale_cli() is None + + @patch("dropkit.main.Path.exists") + @patch("dropkit.main.shutil.which", return_value="/opt/bin/tailscale") + def test_which_returns_path_skips_fallback(self, mock_which, mock_exists): + """Test that PATH hit skips macOS App Store check.""" + assert find_tailscale_cli() == "/opt/bin/tailscale" + mock_exists.assert_not_called() + + class TestCheckLocalTailscale: """Tests for check_local_tailscale function.""" + @patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale") @patch("dropkit.main.subprocess.run") - def test_tailscale_running(self, mock_run): + def test_tailscale_running(self, mock_run, mock_find): """Test when Tailscale is running locally.""" mock_run.return_value = MagicMock( returncode=0, @@ -94,8 +132,9 @@ def test_tailscale_running(self, mock_run): ) assert check_local_tailscale() is True + @patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale") @patch("dropkit.main.subprocess.run") - def test_tailscale_not_running(self, mock_run): + def test_tailscale_not_running(self, mock_run, mock_find): """Test when Tailscale is installed but not running.""" mock_run.return_value = MagicMock( returncode=0, @@ -103,28 +142,30 @@ def test_tailscale_not_running(self, mock_run): ) assert check_local_tailscale() is False + @patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale") @patch("dropkit.main.subprocess.run") - def test_tailscale_command_fails(self, mock_run): + def test_tailscale_command_fails(self, mock_run, mock_find): """Test when tailscale command returns non-zero.""" mock_run.return_value = MagicMock(returncode=1) assert check_local_tailscale() is False - @patch("dropkit.main.subprocess.run") - def test_tailscale_not_installed(self, mock_run): - """Test when tailscale is not installed.""" - mock_run.side_effect = FileNotFoundError() + @patch("dropkit.main.find_tailscale_cli", return_value=None) + def test_tailscale_not_installed(self, mock_find): + """Test when tailscale binary is not found.""" assert check_local_tailscale() is False + @patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale") @patch("dropkit.main.subprocess.run") - def test_tailscale_timeout(self, mock_run): + def test_tailscale_timeout(self, mock_run, mock_find): """Test when tailscale command times out.""" import subprocess mock_run.side_effect = subprocess.TimeoutExpired("tailscale", 5) assert check_local_tailscale() is False + @patch("dropkit.main.find_tailscale_cli", return_value="/usr/bin/tailscale") @patch("dropkit.main.subprocess.run") - def test_invalid_json_response(self, mock_run): + def test_invalid_json_response(self, mock_run, mock_find): """Test when tailscale returns invalid JSON.""" mock_run.return_value = MagicMock( returncode=0,