From 4b8d013ed6db234ea5f002b96563e1f9fa433b6b Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 3 Jun 2026 04:49:23 -0700 Subject: [PATCH] Add bootstrap host preflight checks --- README.md | 4 +- src/timecapsulesmb/cli/bootstrap.py | 160 ++++++++++++++++++++++++++++ tests/test_cli.py | 149 ++++++++++++++++++++++++-- 3 files changed, 303 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 69fa4928..40aaa043 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ For the typical guided setup, you need only: - The password for the Time Capsule - Python 3.9+ - `smbclient` installed locally for `doctor` -- `homebrew` installed for macOS users +- Homebrew installed for macOS users During first-time setup, if necessary `configure` can enable SSH on the Time Capsule. @@ -68,7 +68,7 @@ Run: This command prepares the local Python environment in this folder. It creates the `.venv` folder, installs the Python dependencies needed for discovery, deployment, and verification, and sets up the local `tcapsule` command into that virtualenv. -If `smbclient` or `sshpass` is missing, `bootstrap` will try to install it with Homebrew on macOS or the detected package manager on Linux. On macOS, Homebrew is required for those host tool installs. NetBSD 4 devices need `sshpass` because their firmware does not provide a usable remote `scp`. +If `smbclient` or `sshpass` is missing, `bootstrap` will try to install it with Homebrew on macOS 14+ or the detected package manager on Linux. Older macOS versions can continue only when `smbclient` and `sshpass` are already installed manually. NetBSD 4 devices need `sshpass` because their firmware does not provide a usable remote `scp`. If this is your first time using the repo, this is the only command you should run with the repo-local launcher. After this step, use `.venv/bin/tcapsule ...` to run a command. diff --git a/src/timecapsulesmb/cli/bootstrap.py b/src/timecapsulesmb/cli/bootstrap.py index bfc19875..56a991fe 100644 --- a/src/timecapsulesmb/cli/bootstrap.py +++ b/src/timecapsulesmb/cli/bootstrap.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import platform import subprocess import sys from pathlib import Path @@ -20,6 +21,9 @@ HOMEBREW_INSTALL_COMMAND = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' MACOS_SSHPASS_FORMULA = "sshpass" REQUIRED_HOST_TOOLS = ("sshpass", "smbclient") +MIN_BOOTSTRAP_PYTHON = (3, 9) +MIN_MACOS_AUTO_HOST_TOOL_INSTALL = (14, 0) +PYTHON_VERSION_PROBE = "import sys; print('%d.%d.%d' % sys.version_info[:3])" MACOS_HOST_TOOL_PACKAGES = { "sshpass": MACOS_SSHPASS_FORMULA, "smbclient": "samba", @@ -38,6 +42,12 @@ class BootstrapError(Exception): pass +class BootstrapPreflightError(BootstrapError): + def __init__(self, message: str, fields: dict[str, object]) -> None: + self.fields = fields + super().__init__(message) + + class BootstrapCommandError(Exception): def __init__(self, cmd: list[str], returncode: int, stdout: str, stderr: str) -> None: self.cmd = cmd @@ -99,6 +109,146 @@ def current_platform_label() -> str: return sys.platform +def _format_version_tuple(version: tuple[int, ...]) -> str: + return ".".join(str(part) for part in version) + + +def _parse_version_prefix(value: str) -> tuple[int, ...] | None: + parts: list[int] = [] + for raw_part in value.strip().split("."): + digits = "" + for char in raw_part: + if not char.isdigit(): + break + digits += char + if not digits: + break + parts.append(int(digits)) + return tuple(parts) if parts else None + + +def _version_at_least(version: tuple[int, ...], minimum: tuple[int, ...]) -> bool: + width = max(len(version), len(minimum)) + padded_version = version + (0,) * (width - len(version)) + padded_minimum = minimum + (0,) * (width - len(minimum)) + return padded_version >= padded_minimum + + +def detect_selected_python_version(python: str) -> str: + try: + proc = subprocess.run( + [python, "-c", PYTHON_VERSION_PROBE], + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + check=False, + ) + except OSError as exc: + raise BootstrapError(f"Selected Python could not be run: {python}: {exc}") from exc + + if proc.returncode != 0: + message = f"Selected Python could not report its version: {python} (exit code {proc.returncode})" + output = _format_command_output("stderr", proc.stderr or "") + if output is None: + output = _format_command_output("stdout", proc.stdout or "") + if output is not None: + message = f"{message}\n\n{output}" + raise BootstrapError(message) + + lines = [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()] + if not lines: + raise BootstrapError(f"Selected Python did not print a version: {python}") + return lines[-1] + + +def validate_selected_python(python: str) -> str: + version = detect_selected_python_version(python) + parsed = _parse_version_prefix(version) + minimum = _format_version_tuple(MIN_BOOTSTRAP_PYTHON) + if parsed is None: + raise BootstrapError(f"Selected Python printed an unreadable version: {python}: {version}") + if not _version_at_least(parsed, MIN_BOOTSTRAP_PYTHON): + raise BootstrapError( + f"TimeCapsuleSMB bootstrap requires Python {minimum} or newer. " + f"Selected Python {python} is {version}. " + f"Rerun './tcapsule bootstrap --python /path/to/python{minimum}' with a newer Python." + ) + print(f"Selected Python: {python} ({version})", flush=True) + return version + + +def detect_macos_product_version() -> str | None: + try: + proc = subprocess.run( + ["sw_vers", "-productVersion"], + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + check=False, + ) + except OSError: + proc = None + if proc is not None and proc.returncode == 0: + version = proc.stdout.strip() + if version: + return version + return platform.mac_ver()[0] or None + + +def _required_host_tool_paths() -> dict[str, str | None]: + return {tool: find_command(tool) for tool in REQUIRED_HOST_TOOLS} + + +def _missing_tools_from_paths(paths: dict[str, str | None]) -> list[str]: + return [tool for tool in REQUIRED_HOST_TOOLS if paths.get(tool) is None] + + +def _print_host_tool_status(paths: dict[str, str | None]) -> None: + for tool in REQUIRED_HOST_TOOLS: + path = paths.get(tool) + if path: + print(f"Found {tool}: {path}", flush=True) + else: + print(f"Missing {tool}", flush=True) + + +def check_macos_host_tool_install_support(platform_label: str) -> dict[str, object]: + if platform_label != "macOS": + return {} + + macos_version = detect_macos_product_version() + parsed = _parse_version_prefix(macos_version) if macos_version else None + auto_install_supported = parsed is not None and _version_at_least(parsed, MIN_MACOS_AUTO_HOST_TOOL_INSTALL) + paths = _required_host_tool_paths() + missing_tools = _missing_tools_from_paths(paths) + fields: dict[str, object] = { + "host_os_version": macos_version or "unknown", + "macos_auto_host_tool_install_supported": auto_install_supported, + "missing_host_tools": _format_tools(missing_tools), + "smbclient_path": paths.get("smbclient"), + "sshpass_path": paths.get("sshpass"), + } + if auto_install_supported: + return fields + + print(f"Detected macOS version: {macos_version or 'unknown'}", flush=True) + _print_host_tool_status(paths) + if not missing_tools: + return fields + + minimum = _format_version_tuple(MIN_MACOS_AUTO_HOST_TOOL_INSTALL) + message = ( + f"Automatic TimeCapsuleSMB host-tool install requires macOS {minimum} or newer. " + f"Manually install the missing host tools ({_format_tools(missing_tools)}), " + "then rerun './tcapsule bootstrap'." + ) + print(color_red(message), flush=True) + print(color_red("Required host tools: smbclient and sshpass"), flush=True) + raise BootstrapPreflightError(message, fields) + + def ensure_venv(python: str) -> Path: if not VENVDIR.exists(): print(f"Creating virtualenv at {VENVDIR}", flush=True) @@ -298,6 +448,14 @@ def main(argv: Optional[list[str]] = None) -> int: platform_label = current_platform_label() command_context.update_fields(host_platform_label=platform_label) print(f"Detected host platform: {platform_label}", flush=True) + command_context.set_stage("check_python") + selected_python_version = validate_selected_python(args.python) + command_context.update_fields( + selected_python=args.python, + selected_python_version=selected_python_version, + ) + command_context.set_stage("check_host_support") + command_context.update_fields(**check_macos_host_tool_install_support(platform_label)) command_context.set_stage("ensure_venv") venv_python = ensure_venv(args.python) command_context.update_fields(venv_python=str(venv_python)) @@ -316,6 +474,8 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.fail_with_error(message) return e.returncode or 1 except BootstrapError as e: + if isinstance(e, BootstrapPreflightError): + command_context.update_fields(**e.fields) print(str(e), file=sys.stderr) command_context.fail_with_error(str(e)) return 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index ca42219d..3e8ac9c6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1348,8 +1348,10 @@ def test_bootstrap_prints_full_next_steps(self) -> None: with mock.patch("timecapsulesmb.cli.bootstrap.install_required_host_tools"): with mock.patch("timecapsulesmb.cli.bootstrap.ensure_install_id"): with mock.patch("timecapsulesmb.cli.bootstrap.current_platform_label", return_value="macOS"): - with redirect_stdout(output): - rc = bootstrap.main([]) + with mock.patch("timecapsulesmb.cli.bootstrap.validate_selected_python", return_value="3.11.9"): + with mock.patch("timecapsulesmb.cli.bootstrap.check_macos_host_tool_install_support", return_value={}): + with redirect_stdout(output): + rc = bootstrap.main([]) self.assertEqual(rc, 0) text = output.getvalue() self.assertIn("Detected host platform", text) @@ -1363,6 +1365,7 @@ def test_bootstrap_prints_full_next_steps(self) -> None: self.assertEqual(started["python_executable"], sys.executable) self.assertEqual(finished["result"], "success") self.assertEqual(finished["host_platform_label"], "macOS") + self.assertEqual(finished["selected_python_version"], "3.11.9") def test_bootstrap_prints_same_core_next_steps_on_linux(self) -> None: output = io.StringIO() @@ -1372,8 +1375,9 @@ def test_bootstrap_prints_same_core_next_steps_on_linux(self) -> None: with mock.patch("timecapsulesmb.cli.bootstrap.install_python_requirements"): with mock.patch("timecapsulesmb.cli.bootstrap.install_required_host_tools"): with mock.patch("timecapsulesmb.cli.bootstrap.ensure_install_id"): - with redirect_stdout(output): - rc = bootstrap.main([]) + with mock.patch("timecapsulesmb.cli.bootstrap.validate_selected_python", return_value="3.11.9"): + with redirect_stdout(output): + rc = bootstrap.main([]) self.assertEqual(rc, 0) text = output.getvalue() self.assertIn("Detected host platform: Linux", text) @@ -1419,8 +1423,9 @@ def test_bootstrap_telemetry_error_includes_command_stderr(self) -> None: with mock.patch("timecapsulesmb.cli.bootstrap.VENVDIR", venv): with mock.patch("timecapsulesmb.cli.bootstrap.ensure_install_id"): with mock.patch("timecapsulesmb.cli.bootstrap.current_platform_label", return_value="Linux"): - with mock.patch("timecapsulesmb.cli.bootstrap.subprocess.run", return_value=failed): - rc = bootstrap.main(["--python", "/usr/bin/python3"]) + with mock.patch("timecapsulesmb.cli.bootstrap.validate_selected_python", return_value="3.11.9"): + with mock.patch("timecapsulesmb.cli.bootstrap.subprocess.run", return_value=failed): + rc = bootstrap.main(["--python", "/usr/bin/python3"]) self.assertEqual(rc, 1) finished = self.telemetry_payload("bootstrap_finished") @@ -1444,8 +1449,9 @@ def test_bootstrap_telemetry_error_uses_stdout_when_stderr_empty(self) -> None: with mock.patch("timecapsulesmb.cli.bootstrap.VENVDIR", venv): with mock.patch("timecapsulesmb.cli.bootstrap.ensure_install_id"): with mock.patch("timecapsulesmb.cli.bootstrap.current_platform_label", return_value="Linux"): - with mock.patch("timecapsulesmb.cli.bootstrap.subprocess.run", return_value=failed): - rc = bootstrap.main(["--python", "python3"]) + with mock.patch("timecapsulesmb.cli.bootstrap.validate_selected_python", return_value="3.11.9"): + with mock.patch("timecapsulesmb.cli.bootstrap.subprocess.run", return_value=failed): + rc = bootstrap.main(["--python", "python3"]) self.assertEqual(rc, 1) error = self.telemetry_payload("bootstrap_finished")["error"] @@ -1467,6 +1473,133 @@ def test_bootstrap_command_error_output_is_truncated(self) -> None: self.assertIn("...", message) self.assertLess(len(message), bootstrap.COMMAND_OUTPUT_ERROR_LIMIT + 200) + def test_bootstrap_rejects_selected_python_older_than_minimum_before_venv(self) -> None: + stderr = io.StringIO() + with mock.patch("pathlib.Path.exists", return_value=True): + with mock.patch("timecapsulesmb.cli.bootstrap.ensure_install_id"): + with mock.patch("timecapsulesmb.cli.bootstrap.current_platform_label", return_value="Linux"): + with mock.patch("timecapsulesmb.cli.bootstrap.detect_selected_python_version", return_value="3.8.18"): + with mock.patch("timecapsulesmb.cli.bootstrap.ensure_venv") as ensure_venv: + with redirect_stderr(stderr): + rc = bootstrap.main(["--python", "/usr/local/bin/python3"]) + + self.assertEqual(rc, 1) + ensure_venv.assert_not_called() + self.assertIn("requires Python 3.9 or newer", stderr.getvalue()) + finished = self.telemetry_payload("bootstrap_finished") + self.assertEqual(finished["result"], "failure") + self.assertIn("stage=check_python", finished["error"]) + + def test_bootstrap_accepts_selected_python_at_minimum(self) -> None: + output = io.StringIO() + with mock.patch("timecapsulesmb.cli.bootstrap.detect_selected_python_version", return_value="3.9.0"): + with redirect_stdout(output): + version = bootstrap.validate_selected_python("/usr/local/bin/python3.9") + + self.assertEqual(version, "3.9.0") + self.assertIn("Selected Python: /usr/local/bin/python3.9 (3.9.0)", output.getvalue()) + + def test_bootstrap_blocks_old_macos_missing_tools_before_venv(self) -> None: + output = io.StringIO() + stderr = io.StringIO() + + def fake_which(name: str): + if name == "sshpass": + return "/usr/local/bin/sshpass" + return None + + with mock.patch("pathlib.Path.exists", return_value=True): + with mock.patch("timecapsulesmb.cli.bootstrap.ensure_install_id"): + with mock.patch("timecapsulesmb.cli.bootstrap.current_platform_label", return_value="macOS"): + with mock.patch("timecapsulesmb.cli.bootstrap.validate_selected_python", return_value="3.11.9"): + with mock.patch("timecapsulesmb.cli.bootstrap.detect_macos_product_version", return_value="10.15.7"): + with mock.patch("timecapsulesmb.cli.bootstrap.find_command", side_effect=fake_which): + with mock.patch("timecapsulesmb.cli.bootstrap.ensure_venv") as ensure_venv: + with mock.patch("timecapsulesmb.cli.bootstrap.install_required_host_tools") as install_tools: + with redirect_stdout(output), redirect_stderr(stderr): + rc = bootstrap.main([]) + + self.assertEqual(rc, 1) + ensure_venv.assert_not_called() + install_tools.assert_not_called() + text = output.getvalue() + self.assertIn("Detected macOS version: 10.15.7", text) + self.assertIn("Found sshpass: /usr/local/bin/sshpass", text) + self.assertIn("Missing smbclient", text) + self.assertIn("requires macOS 14.0 or newer", text) + self.assertIn("\033[31m", text) + self.assertIn("missing host tools (smbclient)", stderr.getvalue()) + finished = self.telemetry_payload("bootstrap_finished") + self.assertEqual(finished["host_os_version"], "10.15.7") + self.assertEqual(finished["macos_auto_host_tool_install_supported"], False) + self.assertEqual(finished["missing_host_tools"], "smbclient") + self.assertEqual(finished["sshpass_path"], "/usr/local/bin/sshpass") + self.assertIn("stage=check_host_support", finished["error"]) + + def test_bootstrap_old_macos_continues_when_required_tools_exist(self) -> None: + output = io.StringIO() + + def fake_which(name: str): + if name in {"sshpass", "smbclient"}: + return f"/usr/local/bin/{name}" + return None + + with mock.patch("pathlib.Path.exists", return_value=True): + with mock.patch("timecapsulesmb.cli.bootstrap.ensure_venv", return_value=bootstrap.VENVDIR / "bin" / "python") as ensure_venv: + with mock.patch("timecapsulesmb.cli.bootstrap.install_python_requirements"): + with mock.patch("timecapsulesmb.cli.bootstrap.install_required_host_tools"): + with mock.patch("timecapsulesmb.cli.bootstrap.ensure_install_id"): + with mock.patch("timecapsulesmb.cli.bootstrap.current_platform_label", return_value="macOS"): + with mock.patch("timecapsulesmb.cli.bootstrap.validate_selected_python", return_value="3.11.9"): + with mock.patch("timecapsulesmb.cli.bootstrap.detect_macos_product_version", return_value="10.15.7"): + with mock.patch("timecapsulesmb.cli.bootstrap.find_command", side_effect=fake_which): + with redirect_stdout(output): + rc = bootstrap.main([]) + + self.assertEqual(rc, 0) + ensure_venv.assert_called_once() + text = output.getvalue() + self.assertIn("Detected macOS version: 10.15.7", text) + self.assertIn("Found sshpass: /usr/local/bin/sshpass", text) + self.assertIn("Found smbclient: /usr/local/bin/smbclient", text) + finished = self.telemetry_payload("bootstrap_finished") + self.assertEqual(finished["host_os_version"], "10.15.7") + self.assertEqual(finished["missing_host_tools"], "") + + def test_bootstrap_macos_14_allows_missing_tools_to_reach_installer(self) -> None: + output = io.StringIO() + with mock.patch("pathlib.Path.exists", return_value=True): + with mock.patch("timecapsulesmb.cli.bootstrap.ensure_venv", return_value=bootstrap.VENVDIR / "bin" / "python"): + with mock.patch("timecapsulesmb.cli.bootstrap.install_python_requirements"): + with mock.patch("timecapsulesmb.cli.bootstrap.install_required_host_tools") as install_tools: + with mock.patch("timecapsulesmb.cli.bootstrap.ensure_install_id"): + with mock.patch("timecapsulesmb.cli.bootstrap.current_platform_label", return_value="macOS"): + with mock.patch("timecapsulesmb.cli.bootstrap.validate_selected_python", return_value="3.11.9"): + with mock.patch("timecapsulesmb.cli.bootstrap.detect_macos_product_version", return_value="14.0"): + with mock.patch("timecapsulesmb.cli.bootstrap.find_command", return_value=None): + with redirect_stdout(output): + rc = bootstrap.main([]) + + self.assertEqual(rc, 0) + install_tools.assert_called_once() + self.assertNotIn("requires macOS 14.0 or newer", output.getvalue()) + finished = self.telemetry_payload("bootstrap_finished") + self.assertEqual(finished["macos_auto_host_tool_install_supported"], True) + self.assertEqual(finished["missing_host_tools"], "sshpass, smbclient") + + def test_bootstrap_unknown_macos_version_missing_tools_fails_safely(self) -> None: + output = io.StringIO() + with mock.patch("timecapsulesmb.cli.bootstrap.detect_macos_product_version", return_value=None): + with mock.patch("timecapsulesmb.cli.bootstrap.find_command", return_value=None): + with self.assertRaises(bootstrap.BootstrapError): + with redirect_stdout(output): + bootstrap.check_macos_host_tool_install_support("macOS") + + text = output.getvalue() + self.assertIn("Detected macOS version: unknown", text) + self.assertIn("Missing sshpass", text) + self.assertIn("Missing smbclient", text) + def test_bootstrap_install_python_requirements_repairs_venv_without_pip(self) -> None: output = io.StringIO() venv_python = Path("/tmp/tcapsule-venv/bin/python")