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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
William Tan
2026-02-16 12:48:17 -05:00
committed by GitHub
parent 059fbf02db
commit b5c099840b
2 changed files with 82 additions and 14 deletions
+32 -5
View File
@@ -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]"
)
+50 -9
View File
@@ -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,