From b5c099840b6a5dd7cb1bb74aaab6b91a9938ed17 Mon Sep 17 00:00:00 2001 From: William Tan <1284324+Ninja3047@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:48:17 -0500 Subject: [PATCH] Fix macOS Tailscale CLI discovery for App Store installs (#35) * Fix macOS Tailscale CLI discovery for App Store installs On macOS, Tailscale installed via the App Store places its CLI at /Applications/Tailscale.app/Contents/MacOS/Tailscale, which is not in PATH. Add find_tailscale_cli() helper that checks PATH first, then falls back to the macOS App Store location on darwin. Co-Authored-By: Claude Opus 4.6 * Address PR feedback: better error message, remove excessive test Distinguish "Tailscale not found" from "not running" when the local check fails, so users know whether to install or start Tailscale. Remove the overly comprehensive binary_vanishes race condition test. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- dropkit/main.py | 37 ++++++++++++++++++++++---- tests/test_tailscale.py | 59 ++++++++++++++++++++++++++++++++++------- 2 files changed, 82 insertions(+), 14 deletions(-) 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.config import TailscaleConfig 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 @@ class TestIsTailscaleIP: 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 @@ class TestCheckLocalTailscale: ) 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 @@ class TestCheckLocalTailscale: ) 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,