From 964b9fa9a4f72913ee52fb4da1af1e21e3d7c0de Mon Sep 17 00:00:00 2001 From: William Tan <1284324+Ninja3047@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:48:44 -0500 Subject: [PATCH 1/2] 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 --- dropkit/main.py | 30 +++++++++++++++++-- tests/test_tailscale.py | 66 +++++++++++++++++++++++++++++++++++------ 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/dropkit/main.py b/dropkit/main.py index 12ac7e4..bb740e9 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 diff --git a/tests/test_tailscale.py b/tests/test_tailscale.py index b785dd0..928f812 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, @@ -132,6 +173,13 @@ def test_invalid_json_response(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_binary_vanishes(self, mock_run, mock_find): + """Test race condition: binary found but gone by execution time.""" + mock_run.side_effect = FileNotFoundError() + assert check_local_tailscale() is False + class TestRunTailscaleUp: """Tests for run_tailscale_up function.""" From e2bc5447fb8ee0d562b69deed621b6e39d1e7bd7 Mon Sep 17 00:00:00 2001 From: William Tan <1284324+Ninja3047@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:26:01 -0500 Subject: [PATCH 2/2] 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 --- dropkit/main.py | 7 +++++-- tests/test_tailscale.py | 7 ------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/dropkit/main.py b/dropkit/main.py index bb740e9..60717ef 100644 --- a/dropkit/main.py +++ b/dropkit/main.py @@ -1499,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 928f812..2ee76a2 100644 --- a/tests/test_tailscale.py +++ b/tests/test_tailscale.py @@ -173,13 +173,6 @@ def test_invalid_json_response(self, mock_run, mock_find): ) 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_binary_vanishes(self, mock_run, mock_find): - """Test race condition: binary found but gone by execution time.""" - mock_run.side_effect = FileNotFoundError() - assert check_local_tailscale() is False - class TestRunTailscaleUp: """Tests for run_tailscale_up function."""