From 94aad96e681ee4cebec0af63912ede8d8f5a9779 Mon Sep 17 00:00:00 2001 From: Zafer Date: Mon, 6 Apr 2026 22:31:28 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20add=20manage=5Funity=5Fhub=20tool?= =?UTF-8?q?=20=E2=80=94=20Unity=20Hub=20CLI=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host-side tool for managing Unity Hub and Editor installations. Does NOT require a running Unity Editor instance. 7 actions: - get_hub_info: detect Hub path, OS info - list_installed_editors: list all installed Unity versions - list_available_releases: list downloadable versions - get_install_path / set_install_path: manage editor install location - install_editor: download and install a Unity version - install_modules: add platform modules (Android, iOS, etc.) Features: - Auto-detect Hub on macOS/Windows/Linux + UNITY_HUB_PATH env override - Confirmation required for state-changing actions - Robust subprocess handling with timeout + error recovery - CLI support: unity-mcp hub editors/releases/install/etc. - Raw output always included for parser resilience --- Server/src/cli/commands/unity_hub.py | 129 ++++++++ Server/src/cli/main.py | 1 + Server/src/services/registry/tool_registry.py | 1 + Server/src/services/tools/manage_unity_hub.py | 280 ++++++++++++++++++ Server/src/services/unity_hub.py | 181 +++++++++++ 5 files changed, 592 insertions(+) create mode 100644 Server/src/cli/commands/unity_hub.py create mode 100644 Server/src/services/tools/manage_unity_hub.py create mode 100644 Server/src/services/unity_hub.py diff --git a/Server/src/cli/commands/unity_hub.py b/Server/src/cli/commands/unity_hub.py new file mode 100644 index 000000000..df73f0887 --- /dev/null +++ b/Server/src/cli/commands/unity_hub.py @@ -0,0 +1,129 @@ +"""Unity Hub CLI commands — runs on host, does not require Unity Editor.""" + +import asyncio +import json + +import click + +from cli.utils.output import print_info, print_success +from services.unity_hub import ( + detect_hub_path, + parse_available_releases, + parse_installed_editors, + run_hub_command, +) + + +@click.group("hub") +def unity_hub(): + """Unity Hub operations - editors, releases, install path (host-side, no Unity needed).""" + pass + + +def _run_async(coro): + """Run an async function synchronously.""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + if loop and loop.is_running(): + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor() as pool: + return pool.submit(asyncio.run, coro).result() + return asyncio.run(coro) + + +def _print_result(result: dict) -> None: + click.echo(json.dumps(result, indent=2)) + + +@unity_hub.command("info") +def info() -> None: + """Show detected Unity Hub and host information.""" + import platform as _platform + hub_path = detect_hub_path() + _print_result({ + "hub_detected": hub_path is not None, + "hub_path": hub_path, + "os": _platform.system(), + "architecture": _platform.machine(), + }) + + +@unity_hub.command("editors") +def editors() -> None: + """List locally installed Unity Editor versions.""" + result = _run_async(run_hub_command(["editors", "--installed"])) + if not result["success"]: + _print_result(result) + return + parsed = parse_installed_editors(result["raw_output"]) + for editor in parsed: + click.echo(f" {editor['version']} -> {editor['path']}") + if not parsed: + click.echo(" No editors installed.") + + +@unity_hub.command("releases") +@click.option("--limit", type=int, default=None, help="Maximum number of releases to return.") +def releases(limit: int | None) -> None: + """List Unity Editor releases available through Unity Hub.""" + result = _run_async(run_hub_command(["editors", "--releases"])) + if not result["success"]: + _print_result(result) + return + parsed = parse_available_releases(result["raw_output"], limit) + for release in parsed: + channel = f" ({release['channel']})" if "channel" in release else "" + click.echo(f" {release['version']}{channel}") + + +@unity_hub.command("install-path") +@click.option("--set", "new_path", type=str, default=None, help="Set the Unity Editor install path.") +def install_path(new_path: str | None) -> None: + """Get or set the Unity Editor install path.""" + if new_path is None: + result = _run_async(run_hub_command(["install-path", "--get"])) + if result["success"]: + click.echo(f" Install path: {result['raw_output']}") + else: + _print_result(result) + return + + click.confirm(f"Change Unity Editor install path to '{new_path}'?", abort=True) + result = _run_async(run_hub_command(["install-path", "--set", new_path])) + if result["success"]: + print_success(f"Install path changed to: {new_path}") + else: + _print_result(result) + + +@unity_hub.command("install") +@click.argument("version") +def install(version: str) -> None: + """Download and install a Unity Editor version via Unity Hub.""" + click.confirm(f"Install Unity Editor {version}?", abort=True) + result = _run_async(run_hub_command(["install", "--version", version], timeout=600)) + if result["success"]: + print_success(f"Unity Editor {version} installation started.") + print_info("Unity Hub may continue the install in the background.") + else: + _print_result(result) + + +@unity_hub.command("install-modules") +@click.argument("version") +@click.option("--modules", "-m", multiple=True, required=True, help="Module to install (can repeat: -m android -m ios).") +def install_modules(version: str, modules: tuple[str, ...]) -> None: + """Install platform modules for an existing Unity Editor version.""" + module_list = list(modules) + click.confirm(f"Install modules [{', '.join(module_list)}] for Unity {version}?", abort=True) + args = ["install-modules", "--version", version] + for mod in module_list: + args.extend(["--module", mod]) + result = _run_async(run_hub_command(args, timeout=600)) + if result["success"]: + print_success(f"Module installation started for Unity {version}.") + print_info("Unity Hub may continue the module install in the background.") + else: + _print_result(result) diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py index 44afce32c..2ac91d09c 100644 --- a/Server/src/cli/main.py +++ b/Server/src/cli/main.py @@ -271,6 +271,7 @@ def register_optional_command(module_name: str, command_name: str) -> None: ("cli.commands.camera", "camera"), ("cli.commands.graphics", "graphics"), ("cli.commands.packages", "packages"), + ("cli.commands.unity_hub", "unity_hub"), ("cli.commands.reflect", "reflect"), ("cli.commands.docs", "docs"), ("cli.commands.physics", "physics"), diff --git a/Server/src/services/registry/tool_registry.py b/Server/src/services/registry/tool_registry.py index 069b44ab9..e12bb526d 100644 --- a/Server/src/services/registry/tool_registry.py +++ b/Server/src/services/registry/tool_registry.py @@ -18,6 +18,7 @@ TOOL_GROUPS: dict[str, str] = { "core": "Essential scene, script, asset & editor tools (always on by default)", "docs": "Unity API reflection and documentation lookup", + "unity_hub": "Host-side Unity Hub and editor installation management", "vfx": "Visual effects – VFX Graph, shaders, procedural textures", "animation": "Animator control & AnimationClip creation", "ui": "UI Toolkit (UXML, USS, UIDocument)", diff --git a/Server/src/services/tools/manage_unity_hub.py b/Server/src/services/tools/manage_unity_hub.py new file mode 100644 index 000000000..aeab7a89c --- /dev/null +++ b/Server/src/services/tools/manage_unity_hub.py @@ -0,0 +1,280 @@ +"""MCP tool for managing Unity Hub and Unity Editor installations on the host machine.""" + +from typing import Annotated, Any, Optional + +from mcp.types import ToolAnnotations + +from services.registry import mcp_for_unity_tool +from services.unity_hub import ( + _INSTALL_TIMEOUT, + detect_hub_path, + parse_available_releases, + parse_installed_editors, + run_hub_command, +) + + +ALL_ACTIONS = [ + "get_hub_info", + "list_installed_editors", + "list_available_releases", + "get_install_path", + "set_install_path", + "install_editor", + "install_modules", +] + +READ_ONLY_ACTIONS = { + "get_hub_info", + "list_installed_editors", + "list_available_releases", + "get_install_path", +} + + +@mcp_for_unity_tool( + group="unity_hub", + unity_target=None, + description=( + "Manage Unity Hub and Unity Editor installations on the host machine.\n\n" + "This tool interacts with the Unity Hub CLI directly on the host - " + "it does NOT require a running Unity Editor instance.\n\n" + "READ-ONLY:\n" + "- get_hub_info: Detect Hub installation, show path and OS info\n" + "- list_installed_editors: List all locally installed Unity Editor versions\n" + "- list_available_releases: List available Unity Editor versions for download\n" + "- get_install_path: Get the current Unity Editor install location\n\n" + "STATE-CHANGING (requires confirmation):\n" + "- set_install_path: Change where Unity Editors are installed\n" + "- install_editor: Download and install a Unity Editor version\n" + "- install_modules: Add platform modules (Android, iOS, etc.) to an installed editor" + ), + annotations=ToolAnnotations( + title="Manage Unity Hub", + destructiveHint=True, + readOnlyHint=False, + idempotentHint=False, + openWorldHint=True, + ), +) +async def manage_unity_hub( + action: Annotated[str, "The Hub action to perform."], + version: Annotated[ + Optional[str], + "Unity Editor version (e.g., '2022.3.0f1', '6000.0.0f1').", + ] = None, + modules: Annotated[ + Optional[list[str]], + "Platform modules to install (e.g., ['android', 'ios', 'webgl']).", + ] = None, + path: Annotated[ + Optional[str], + "File system path for set_install_path.", + ] = None, + limit: Annotated[ + Optional[int], + "Max number of releases to return for list_available_releases.", + ] = None, + confirm: Annotated[ + Optional[bool], + "Set to true to confirm state-changing actions.", + ] = None, +) -> dict[str, Any]: + action_lower = action.lower().strip() + + if action_lower not in ALL_ACTIONS: + return { + "success": False, + "message": f"Unknown action '{action}'. Valid actions: {', '.join(ALL_ACTIONS)}", + } + + if action_lower not in READ_ONLY_ACTIONS and not confirm: + hub_path = detect_hub_path() or "not found" + details = _build_confirmation_message( + action_lower, + hub_path, + version, + modules, + path, + ) + return { + "success": False, + "confirmation_required": True, + "message": details, + "hint": "Set confirm=true to proceed.", + } + + if action_lower == "get_hub_info": + return await _get_hub_info() + if action_lower == "list_installed_editors": + return await _list_installed_editors() + if action_lower == "list_available_releases": + return await _list_available_releases(limit) + if action_lower == "get_install_path": + return await _get_install_path() + if action_lower == "set_install_path": + return await _set_install_path(path) + if action_lower == "install_editor": + return await _install_editor(version) + if action_lower == "install_modules": + return await _install_modules(version, modules) + + return {"success": False, "message": "Action not implemented."} + + +def _build_confirmation_message( + action: str, + hub_path: str, + version: Optional[str], + modules: Optional[list[str]], + path: Optional[str], +) -> str: + if action == "install_editor": + return f"Install Unity Editor {version or '(version required)'} using Hub at '{hub_path}'?" + if action == "install_modules": + mods = ", ".join(modules) if modules else "(modules required)" + return f"Install modules [{mods}] for Unity {version or '(version required)'} using Hub at '{hub_path}'?" + if action == "set_install_path": + return f"Change Unity Editor install path to '{path or '(path required)'}' using Hub at '{hub_path}'?" + return f"Execute '{action}' on Hub at '{hub_path}'?" + + +async def _get_hub_info() -> dict[str, Any]: + import platform as _platform + + hub_path = detect_hub_path() + return { + "success": True, + "action": "get_hub_info", + "data": { + "hub_detected": hub_path is not None, + "hub_path": hub_path, + "os": _platform.system(), + "os_version": _platform.version(), + "architecture": _platform.machine(), + }, + } + + +async def _list_installed_editors() -> dict[str, Any]: + result = await run_hub_command(["editors", "--installed"]) + if not result["success"]: + return {**result, "action": "list_installed_editors"} + + editors = parse_installed_editors(result["raw_output"]) + return { + "success": True, + "action": "list_installed_editors", + "hub_path": result["hub_path"], + "data": editors, + "raw_output": result["raw_output"], + } + + +async def _list_available_releases(limit: Optional[int]) -> dict[str, Any]: + result = await run_hub_command(["editors", "--releases"]) + if not result["success"]: + return {**result, "action": "list_available_releases"} + + releases = parse_available_releases(result["raw_output"], limit) + return { + "success": True, + "action": "list_available_releases", + "hub_path": result["hub_path"], + "data": releases, + "raw_output": result["raw_output"], + } + + +async def _get_install_path() -> dict[str, Any]: + result = await run_hub_command(["install-path", "--get"]) + if not result["success"]: + return {**result, "action": "get_install_path"} + + return { + "success": True, + "action": "get_install_path", + "hub_path": result["hub_path"], + "data": {"install_path": result["raw_output"]}, + } + + +async def _set_install_path(path: Optional[str]) -> dict[str, Any]: + if not path: + return { + "success": False, + "action": "set_install_path", + "message": "path is required.", + } + + result = await run_hub_command(["install-path", "--set", path]) + if not result["success"]: + return {**result, "action": "set_install_path"} + + return { + "success": True, + "action": "set_install_path", + "hub_path": result["hub_path"], + "data": {"install_path": path}, + "message": f"Install path changed to: {path}", + } + + +async def _install_editor(version: Optional[str]) -> dict[str, Any]: + if not version: + return { + "success": False, + "action": "install_editor", + "message": "version is required.", + } + + result = await run_hub_command( + ["install", "--version", version], + timeout=_INSTALL_TIMEOUT, + ) + if not result["success"]: + return {**result, "action": "install_editor"} + + return { + "success": True, + "action": "install_editor", + "hub_path": result["hub_path"], + "data": {"version": version}, + "message": f"Unity Editor {version} installation started.", + "raw_output": result["raw_output"], + } + + +async def _install_modules( + version: Optional[str], + modules: Optional[list[str]], +) -> dict[str, Any]: + if not version: + return { + "success": False, + "action": "install_modules", + "message": "version is required.", + } + if not modules: + return { + "success": False, + "action": "install_modules", + "message": "modules list is required and must not be empty.", + } + + args = ["install-modules", "--version", version] + for module_name in modules: + args.extend(["--module", module_name]) + + result = await run_hub_command(args, timeout=_INSTALL_TIMEOUT) + if not result["success"]: + return {**result, "action": "install_modules"} + + return { + "success": True, + "action": "install_modules", + "hub_path": result["hub_path"], + "data": {"version": version, "modules": modules}, + "message": f"Modules {modules} installation started for Unity {version}.", + "raw_output": result["raw_output"], + } diff --git a/Server/src/services/unity_hub.py b/Server/src/services/unity_hub.py new file mode 100644 index 000000000..d67bdbf80 --- /dev/null +++ b/Server/src/services/unity_hub.py @@ -0,0 +1,181 @@ +"""Unity Hub CLI integration - runs on the host machine, not inside Unity Editor.""" + +import asyncio +import os +import platform +import shutil +from typing import Any, Optional + + +_HUB_PATHS = { + "Darwin": ["/Applications/Unity Hub.app/Contents/MacOS/Unity Hub"], + "Windows": [ + r"C:\Program Files\Unity Hub\Unity Hub.exe", + r"C:\Program Files (x86)\Unity Hub\Unity Hub.exe", + ], + "Linux": ["/usr/bin/unityhub", "/snap/bin/unityhub"], +} + +_DEFAULT_TIMEOUT = 30 +_INSTALL_TIMEOUT = 600 + + +def detect_hub_path() -> Optional[str]: + """Find the Unity Hub executable on the host machine.""" + env_path = os.environ.get("UNITY_HUB_PATH") + if env_path and os.path.isfile(env_path): + return env_path + + system = platform.system() + for path in _HUB_PATHS.get(system, []): + if os.path.isfile(path): + return path + + which = shutil.which("unityhub") or shutil.which("Unity Hub") + if which: + return which + + return None + + +async def run_hub_command( + args: list[str], + timeout: int = _DEFAULT_TIMEOUT, + hub_path: Optional[str] = None, +) -> dict[str, Any]: + """Run a Unity Hub CLI command and return a structured result.""" + hub = hub_path or detect_hub_path() + if not hub: + searched = _HUB_PATHS.get(platform.system(), []) + return { + "success": False, + "error": { + "type": "hub_not_found", + "message": ( + "Unity Hub executable not found. " + f"Searched: {searched}. Set UNITY_HUB_PATH env var to override." + ), + }, + } + + cmd = [hub, "--", "--headless", *args] + + try: + proc = await asyncio.wait_for( + asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ), + timeout=5, + ) + stdout_bytes, stderr_bytes = await asyncio.wait_for( + proc.communicate(), + timeout=timeout, + ) + except asyncio.TimeoutError: + return { + "success": False, + "error": { + "type": "timeout", + "message": f"Hub command timed out after {timeout}s", + "command": args, + }, + } + except FileNotFoundError: + return { + "success": False, + "error": { + "type": "hub_not_found", + "message": f"Hub executable not found at: {hub}", + }, + } + except Exception as exc: + return { + "success": False, + "error": { + "type": "subprocess_error", + "message": str(exc), + }, + } + + stdout = stdout_bytes.decode("utf-8", errors="replace").strip() + stderr = stderr_bytes.decode("utf-8", errors="replace").strip() + + if proc.returncode != 0: + return { + "success": False, + "hub_path": hub, + "error": { + "type": "hub_command_failed", + "message": stderr or stdout or f"Exit code {proc.returncode}", + "exit_code": proc.returncode, + "stderr": stderr, + "stdout": stdout, + }, + } + + return { + "success": True, + "hub_path": hub, + "raw_output": stdout, + "stderr": stderr if stderr else None, + } + + +def parse_installed_editors(raw_output: str) -> list[dict[str, str]]: + """Parse `editors --installed` output into a structured list.""" + editors: list[dict[str, str]] = [] + for line in raw_output.strip().splitlines(): + line = line.strip() + if not line: + continue + + # Format: "6000.3.9f1 (Apple silicon) installed at /path/to/Unity.app" + # or: "2022.3.0f1 , installed at /path/to/editor" + path = "" + installed_at_idx = line.lower().find("installed at") + if installed_at_idx >= 0: + path = line[installed_at_idx + len("installed at"):].strip() + version_part = line[:installed_at_idx].strip().rstrip(",").strip() + else: + parts = line.split(",", 1) + version_part = parts[0].strip() + if len(parts) > 1: + path = parts[1].strip() + + # Extract clean version (first token before any parenthetical) + version = version_part.split("(")[0].strip().split()[0] if version_part else "" + + if version: + editors.append({"version": version, "path": path}) + + return editors + + +def parse_available_releases( + raw_output: str, + limit: Optional[int] = None, +) -> list[dict[str, str]]: + """Parse `editors --releases` output into a structured list.""" + releases: list[dict[str, str]] = [] + for line in raw_output.strip().splitlines(): + line = line.strip() + if not line: + continue + + version = line.split(",", 1)[0].strip().split(" ")[0].strip() + if not version: + continue + + entry = {"version": version} + if "LTS" in line: + entry["channel"] = "LTS" + elif "Tech" in line: + entry["channel"] = "Tech" + releases.append(entry) + + if limit and limit > 0: + releases = releases[:limit] + + return releases From 7ea7e2f07f530f2fd5e95999379b60e878664d6b Mon Sep 17 00:00:00 2001 From: Zafer Date: Mon, 6 Apr 2026 22:37:33 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20code=20review=20=E2=80=94?= =?UTF-8?q?=20spawn=20timeout=20and=20CLI=20timeout=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded 5s spawn timeout with _SPAWN_TIMEOUT (15s), capped at the caller's timeout value for short timeouts - CLI commands now use _INSTALL_TIMEOUT from service module instead of hardcoded 600 --- Server/src/cli/commands/unity_hub.py | 5 +++-- Server/src/services/unity_hub.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Server/src/cli/commands/unity_hub.py b/Server/src/cli/commands/unity_hub.py index df73f0887..f7d6ff2d0 100644 --- a/Server/src/cli/commands/unity_hub.py +++ b/Server/src/cli/commands/unity_hub.py @@ -7,6 +7,7 @@ from cli.utils.output import print_info, print_success from services.unity_hub import ( + _INSTALL_TIMEOUT, detect_hub_path, parse_available_releases, parse_installed_editors, @@ -103,7 +104,7 @@ def install_path(new_path: str | None) -> None: def install(version: str) -> None: """Download and install a Unity Editor version via Unity Hub.""" click.confirm(f"Install Unity Editor {version}?", abort=True) - result = _run_async(run_hub_command(["install", "--version", version], timeout=600)) + result = _run_async(run_hub_command(["install", "--version", version], timeout=_INSTALL_TIMEOUT)) if result["success"]: print_success(f"Unity Editor {version} installation started.") print_info("Unity Hub may continue the install in the background.") @@ -121,7 +122,7 @@ def install_modules(version: str, modules: tuple[str, ...]) -> None: args = ["install-modules", "--version", version] for mod in module_list: args.extend(["--module", mod]) - result = _run_async(run_hub_command(args, timeout=600)) + result = _run_async(run_hub_command(args, timeout=_INSTALL_TIMEOUT)) if result["success"]: print_success(f"Module installation started for Unity {version}.") print_info("Unity Hub may continue the module install in the background.") diff --git a/Server/src/services/unity_hub.py b/Server/src/services/unity_hub.py index d67bdbf80..ac090931d 100644 --- a/Server/src/services/unity_hub.py +++ b/Server/src/services/unity_hub.py @@ -18,6 +18,7 @@ _DEFAULT_TIMEOUT = 30 _INSTALL_TIMEOUT = 600 +_SPAWN_TIMEOUT = 15 def detect_hub_path() -> Optional[str]: @@ -61,13 +62,14 @@ async def run_hub_command( cmd = [hub, "--", "--headless", *args] try: + spawn_timeout = min(_SPAWN_TIMEOUT, timeout) proc = await asyncio.wait_for( asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ), - timeout=5, + timeout=spawn_timeout, ) stdout_bytes, stderr_bytes = await asyncio.wait_for( proc.communicate(), From 6c4f934499e666ecc171f597088f31d18ea978ea Mon Sep 17 00:00:00 2001 From: Zafer Date: Sun, 3 May 2026 18:31:58 +0100 Subject: [PATCH 3/3] test: pytest coverage for manage_unity_hub Add 7 pytest cases (Server/tests/integration/test_manage_unity_hub.py) covering: - list_installed_editors: tool dispatch + Hub args + parser integration - parse_installed_editors: direct parser test against realistic Hub output - set_install_path: confirmation gate (confirm omitted -> no subprocess) - install_editor: confirm=True invokes Hub with version + _INSTALL_TIMEOUT - install_modules: confirm=True repeats --module per item - detect_hub_path: UNITY_HUB_PATH env override - run_hub_command: hub_not_found error when detection fails Mocks run_hub_command and detect_hub_path; no real Hub required. --- .../integration/test_manage_unity_hub.py | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 Server/tests/integration/test_manage_unity_hub.py diff --git a/Server/tests/integration/test_manage_unity_hub.py b/Server/tests/integration/test_manage_unity_hub.py new file mode 100644 index 000000000..896aded2e --- /dev/null +++ b/Server/tests/integration/test_manage_unity_hub.py @@ -0,0 +1,178 @@ +""" +Tests for the manage_unity_hub tool and its underlying service. + +Covers MCP-tool dispatch, the confirmation gate for state-changing actions, +service-layer parsing, and Unity Hub auto-detection (UNITY_HUB_PATH override +and hub-not-found error). + +Tests do NOT require a real Unity Hub installation - the subprocess and +detection layers are mocked. +""" +import pytest + +import services.tools.manage_unity_hub as manage_hub_mod +import services.unity_hub as unity_hub_mod + + +@pytest.mark.asyncio +async def test_list_installed_editors_invokes_hub_and_parses_output(monkeypatch): + """list_installed_editors calls Hub with the right args and returns parsed editors.""" + captured = {} + + async def fake_run(args, timeout=30, hub_path=None): + captured["args"] = args + captured["timeout"] = timeout + return { + "success": True, + "hub_path": "/Applications/Unity Hub.app/Contents/MacOS/Unity Hub", + "raw_output": ( + "6000.3.10f1 (Apple silicon) installed at /Applications/Unity/Hub/Editor/6000.3.10f1\n" + "2022.3.0f1 , installed at /Applications/Unity/Hub/Editor/2022.3.0f1" + ), + "stderr": None, + } + + monkeypatch.setattr(manage_hub_mod, "run_hub_command", fake_run) + + resp = await manage_hub_mod.manage_unity_hub(action="list_installed_editors") + + assert resp["success"] is True + assert resp["action"] == "list_installed_editors" + assert captured["args"] == ["editors", "--installed"] + assert resp["data"] == [ + {"version": "6000.3.10f1", "path": "/Applications/Unity/Hub/Editor/6000.3.10f1"}, + {"version": "2022.3.0f1", "path": "/Applications/Unity/Hub/Editor/2022.3.0f1"}, + ] + + +def test_parse_installed_editors_handles_realistic_output(): + """parse_installed_editors handles both 'installed at' and comma-separated formats.""" + raw = ( + "6000.3.10f1 (Apple silicon) installed at /Applications/Unity/Hub/Editor/6000.3.10f1\n" + "2022.3.0f1 , installed at /Applications/Unity/Hub/Editor/2022.3.0f1\n" + "\n" # blank line should be skipped + ) + + result = unity_hub_mod.parse_installed_editors(raw) + + assert result == [ + {"version": "6000.3.10f1", "path": "/Applications/Unity/Hub/Editor/6000.3.10f1"}, + {"version": "2022.3.0f1", "path": "/Applications/Unity/Hub/Editor/2022.3.0f1"}, + ] + + +@pytest.mark.asyncio +async def test_set_install_path_confirm_false_does_not_invoke_hub(monkeypatch): + """State-changing action without confirm=True must not invoke the Hub subprocess.""" + invoked = {"called": False} + + async def fake_run(*args, **kwargs): + invoked["called"] = True + return {"success": True, "hub_path": "/dummy", "raw_output": ""} + + monkeypatch.setattr(manage_hub_mod, "run_hub_command", fake_run) + monkeypatch.setattr( + manage_hub_mod, "detect_hub_path", lambda: "/dummy/Unity Hub" + ) + + resp = await manage_hub_mod.manage_unity_hub( + action="set_install_path", + path="/Applications/Unity/Hub/Editor", + ) + + assert resp["success"] is False + assert resp.get("confirmation_required") is True + assert resp.get("hint") + assert invoked["called"] is False + + +@pytest.mark.asyncio +async def test_install_editor_confirm_true_invokes_hub_with_install_timeout(monkeypatch): + """install_editor with confirm=True invokes Hub install with version arg and install timeout.""" + captured = {} + + async def fake_run(args, timeout=30, hub_path=None): + captured["args"] = args + captured["timeout"] = timeout + return { + "success": True, + "hub_path": "/dummy/Unity Hub", + "raw_output": "Installation started", + "stderr": None, + } + + monkeypatch.setattr(manage_hub_mod, "run_hub_command", fake_run) + monkeypatch.setattr( + manage_hub_mod, "detect_hub_path", lambda: "/dummy/Unity Hub" + ) + + resp = await manage_hub_mod.manage_unity_hub( + action="install_editor", + version="6000.3.10f1", + confirm=True, + ) + + assert resp["success"] is True + assert resp["action"] == "install_editor" + assert captured["args"] == ["install", "--version", "6000.3.10f1"] + assert captured["timeout"] == unity_hub_mod._INSTALL_TIMEOUT + + +@pytest.mark.asyncio +async def test_install_modules_confirm_true_passes_each_module(monkeypatch): + """install_modules with confirm=True forwards each module via repeated --module flags.""" + captured = {} + + async def fake_run(args, timeout=30, hub_path=None): + captured["args"] = args + captured["timeout"] = timeout + return { + "success": True, + "hub_path": "/dummy/Unity Hub", + "raw_output": "Modules installation started", + "stderr": None, + } + + monkeypatch.setattr(manage_hub_mod, "run_hub_command", fake_run) + monkeypatch.setattr( + manage_hub_mod, "detect_hub_path", lambda: "/dummy/Unity Hub" + ) + + resp = await manage_hub_mod.manage_unity_hub( + action="install_modules", + version="6000.3.10f1", + modules=["android", "ios"], + confirm=True, + ) + + assert resp["success"] is True + assert resp["action"] == "install_modules" + assert captured["args"] == [ + "install-modules", + "--version", "6000.3.10f1", + "--module", "android", + "--module", "ios", + ] + assert captured["timeout"] == unity_hub_mod._INSTALL_TIMEOUT + + +def test_detect_hub_path_uses_unity_hub_path_env_override(monkeypatch, tmp_path): + """UNITY_HUB_PATH env var takes precedence when it points at an existing file.""" + fake_hub = tmp_path / "Unity Hub" + fake_hub.write_text("#!/bin/sh\nexit 0\n") + + monkeypatch.setenv("UNITY_HUB_PATH", str(fake_hub)) + + assert unity_hub_mod.detect_hub_path() == str(fake_hub) + + +@pytest.mark.asyncio +async def test_run_hub_command_returns_hub_not_found_when_detection_fails(monkeypatch): + """run_hub_command surfaces a structured hub_not_found error when detection returns None.""" + monkeypatch.setattr(unity_hub_mod, "detect_hub_path", lambda: None) + + result = await unity_hub_mod.run_hub_command(["editors", "--installed"]) + + assert result["success"] is False + assert result["error"]["type"] == "hub_not_found" + assert "UNITY_HUB_PATH" in result["error"]["message"]