From 81f57df85a3c4bc9a53073ee63610dbd04732559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Wr=C3=B3bel?= Date: Fri, 10 Jul 2026 09:45:51 +0200 Subject: [PATCH] feat(tui,mobile): add Extra Dependencies installer + fix SDK script idempotency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Extra Dependencies: a section listing on-demand provisioning scripts (currently the mobile SDKs) with their versions and a one-click Install button that streams progress/logs. Installing runs the in-image script via `docker exec` into the backend container, so the script stays the single source of truth for what gets installed. - tui/extra_deps.py: data model (ExtraDependencyScript catalog); adding another installer is a single entry (Open/Closed). - local_env.exec_in_backend(): streamed `docker exec ...` helper, reusing the existing subprocess streamer. - SettingsScreen: new section, Install handler, output log; refuses early when containers are down. Also fix init-mobile-sdk.sh idempotency: the Android/Flutter "already done?" checks keyed on the FIRST artifact each block creates (sdkmanager binary / flutter checkout), both of which exist before licenses + the heavy package install run. A failure mid-install left the component looking done, so a re-run skipped it instead of finishing it. Switch to a completion-marker written only after all steps succeed; `set -e` guarantees a partial run leaves no marker and is retried in full, while the heavy downloads stay internally guarded so a retry reuses what's present. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Dockerfile | 19 ++++-- backend/scripts/init-mobile-sdk.sh | 62 +++++++++++++------- mcp_server/services/local_env.py | 23 ++++++++ mcp_server/tests/test_extra_deps.py | 47 +++++++++++++++ mcp_server/tests/test_local_env.py | 24 ++++++++ mcp_server/tests/test_tui_app.py | 89 ++++++++++++++++++++++++++++- mcp_server/tui/app.py | 71 ++++++++++++++++++++++- mcp_server/tui/extra_deps.py | 87 ++++++++++++++++++++++++++++ 8 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 mcp_server/tests/test_extra_deps.py create mode 100644 mcp_server/tui/extra_deps.py diff --git a/backend/Dockerfile b/backend/Dockerfile index 78166ba..ee165b7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -32,11 +32,20 @@ RUN apt-get update && apt-get install -y \ maven \ && rm -rf /var/lib/apt/lists/* -# Install Go 1.23 (latest stable) -RUN wget https://go.dev/dl/go1.23.5.linux-amd64.tar.gz \ - && tar -C /usr/local -xzf go1.23.5.linux-amd64.tar.gz \ - && rm go1.23.5.linux-amd64.tar.gz -ENV JAVA_HOME="/usr/lib/jvm/java-21-openjdk-amd64" +# Install Go 1.23 (latest stable). The tarball is arch-specific — `dpkg --print-architecture` +# yields amd64/arm64, which matches Go's naming — so this builds on both x86_64 CI and +# Apple-Silicon (arm64) hosts instead of installing an unrunnable amd64 binary on arm64. +RUN GOARCH="$(dpkg --print-architecture)" \ + && wget "https://go.dev/dl/go1.23.5.linux-${GOARCH}.tar.gz" \ + && tar -C /usr/local -xzf "go1.23.5.linux-${GOARCH}.tar.gz" \ + && rm "go1.23.5.linux-${GOARCH}.tar.gz" + +# JAVA_HOME must point at the JDK dir, which Debian names with an arch suffix +# (java-21-openjdk-amd64 / -arm64). `java` on PATH works either way via update-alternatives, +# but sdkmanager/gradle read JAVA_HOME directly and fail hard if it's wrong — so pin a stable, +# arch-independent symlink and point JAVA_HOME at that (fixes sdkmanager on arm64 hosts). +RUN ln -s "/usr/lib/jvm/java-21-openjdk-$(dpkg --print-architecture)" /usr/lib/jvm/java-21-openjdk +ENV JAVA_HOME="/usr/lib/jvm/java-21-openjdk" ENV PATH="${JAVA_HOME}/bin:${PATH}" # Gradle — small JVM build-tool binary, installed like the other runtimes (Go/Java). diff --git a/backend/scripts/init-mobile-sdk.sh b/backend/scripts/init-mobile-sdk.sh index 90b0d2a..7bdcfa9 100755 --- a/backend/scripts/init-mobile-sdk.sh +++ b/backend/scripts/init-mobile-sdk.sh @@ -5,8 +5,14 @@ # JDK, Gradle, Kotlin) are baked into the Docker image; only the large SDKs that are # impractical to bake — the Android SDK and the Flutter SDK — live here. # -# Idempotent per component: an already-provisioned SDK is detected and skipped, so this -# script is safe to re-run (e.g. after bumping a version below). +# Idempotent per component via a COMPLETION MARKER: each component writes its marker file +# only AFTER every one of its steps has succeeded. Because `set -e` aborts the script the +# moment any step fails, a component that fails partway (e.g. a transient download during the +# multi-GB Android package install) never gets its marker and is therefore RETRIED in full on +# the next run. Keying the skip on the marker — not on the first artifact a block happens to +# create (the sdkmanager binary / the flutter checkout, both of which exist before licenses and +# package install run) — is what makes a re-run actually finish a half-done install rather than +# skip it. The heavy downloads inside each block stay guarded so a retry reuses what's present. set -eu # --------------------------------------------------------------------------- @@ -15,18 +21,23 @@ set -eu ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-/workspaces/caches/common/android}" ANDROID_SDK_CMDLINE_TOOLS_VERSION="${ANDROID_SDK_CMDLINE_TOOLS_VERSION:-11076708}" SDKMANAGER="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager" +ANDROID_MARKER="${ANDROID_SDK_ROOT}/.specflow-provisioned" -if [ -x "${SDKMANAGER}" ]; then - echo "Android SDK already exists in volume. Skipping download." +if [ -f "${ANDROID_MARKER}" ]; then + echo "Android SDK already provisioned. Skipping." else - echo "Android SDK not found in volume. Downloading..." - - mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" - wget -q "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_CMDLINE_TOOLS_VERSION}_latest.zip" -O /tmp/android-cmdline-tools.zip - unzip -q /tmp/android-cmdline-tools.zip -d /tmp/android-cmdline-tools - mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools/latest" - mv /tmp/android-cmdline-tools/cmdline-tools/* "${ANDROID_SDK_ROOT}/cmdline-tools/latest/" - rm -rf /tmp/android-cmdline-tools /tmp/android-cmdline-tools.zip + # cmdline-tools bootstrap is itself idempotent: download+extract only when sdkmanager is + # absent, so a retry after a failed package install reuses the already-extracted tools. + if [ ! -x "${SDKMANAGER}" ]; then + echo "Downloading Android cmdline-tools..." + mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools" + wget -q "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_CMDLINE_TOOLS_VERSION}_latest.zip" -O /tmp/android-cmdline-tools.zip + rm -rf /tmp/android-cmdline-tools + unzip -q /tmp/android-cmdline-tools.zip -d /tmp/android-cmdline-tools + mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools/latest" + mv /tmp/android-cmdline-tools/cmdline-tools/* "${ANDROID_SDK_ROOT}/cmdline-tools/latest/" + rm -rf /tmp/android-cmdline-tools /tmp/android-cmdline-tools.zip + fi echo "Accepting licenses and installing packages..." yes | "${SDKMANAGER}" --sdk_root="${ANDROID_SDK_ROOT}" --licenses > /dev/null @@ -36,7 +47,9 @@ else # # Several recent platform / build-tools versions are pre-installed so projects targeting # different API levels find a match without each agent running sdkmanager against this - # SHARED, read-only SDK (which would race across concurrent workspaces). + # SHARED, read-only SDK (which would race across concurrent workspaces). sdkmanager is + # idempotent — already-installed packages are a fast no-op — so re-running this after a + # partial failure only fills the gaps. "${SDKMANAGER}" --sdk_root="${ANDROID_SDK_ROOT}" \ "platform-tools" \ "build-tools;34.0.0" \ @@ -48,6 +61,7 @@ else "cmake;3.22.1" \ "ndk;27.1.12297006" > /dev/null + touch "${ANDROID_MARKER}" echo "Android SDK initialization complete." fi @@ -66,20 +80,28 @@ fi # --------------------------------------------------------------------------- FLUTTER_TEMPLATE_ROOT="${FLUTTER_TEMPLATE_ROOT:-/workspaces/caches/common/flutter}" FLUTTER_VERSION="${FLUTTER_VERSION:-3.27.4}" +FLUTTER_MARKER="${FLUTTER_TEMPLATE_ROOT}/.specflow-provisioned" -if [ -x "${FLUTTER_TEMPLATE_ROOT}/bin/flutter" ]; then - echo "Flutter template already exists in volume. Skipping download." +if [ -f "${FLUTTER_MARKER}" ]; then + echo "Flutter template already provisioned. Skipping." else - echo "Flutter template not found in volume. Downloading..." - - mkdir -p "$(dirname "${FLUTTER_TEMPLATE_ROOT}")" - git clone --depth 1 --branch "${FLUTTER_VERSION}" https://github.com/flutter/flutter.git "${FLUTTER_TEMPLATE_ROOT}" + # Clone only when the checkout is absent/incomplete. `rm -rf` first so a retry after an + # interrupted clone doesn't fail on `git clone` into a non-empty dir; a good checkout from a + # prior run (failed later, at config/precache) is kept and reused. + if [ ! -x "${FLUTTER_TEMPLATE_ROOT}/bin/flutter" ]; then + echo "Downloading Flutter template..." + rm -rf "${FLUTTER_TEMPLATE_ROOT}" + mkdir -p "$(dirname "${FLUTTER_TEMPLATE_ROOT}")" + git clone --depth 1 --branch "${FLUTTER_VERSION}" https://github.com/flutter/flutter.git "${FLUTTER_TEMPLATE_ROOT}" + fi # Bootstrap the bundled Dart SDK and pre-warm Android build artefacts so per-workspace - # copies do minimal first-run downloading; disable telemetry for unattended runs. + # copies do minimal first-run downloading; disable telemetry for unattended runs. Both are + # idempotent, so they re-run cleanly on a retry that reused an existing checkout. "${FLUTTER_TEMPLATE_ROOT}/bin/flutter" config --no-analytics > /dev/null "${FLUTTER_TEMPLATE_ROOT}/bin/flutter" precache --android > /dev/null + touch "${FLUTTER_MARKER}" echo "Flutter template initialization complete." fi diff --git a/mcp_server/services/local_env.py b/mcp_server/services/local_env.py index 503c91b..ad17d36 100644 --- a/mcp_server/services/local_env.py +++ b/mcp_server/services/local_env.py @@ -195,6 +195,11 @@ def _container_names() -> tuple[str, str]: return backend, firestore +def backend_container_name() -> str: + """Name of the backend container (env override or compose default).""" + return _container_names()[0] + + def containers_running(root: Path | None = None) -> bool: """True iff BOTH SpecFlow containers are currently running. @@ -359,6 +364,24 @@ async def start_containers(root: Path, on_line: Callable[[str], None] | None = N return await _stream_subprocess(["docker", "compose", "up", "-d", "--no-build"], root, on_line) +async def exec_in_backend( + root: Path, + in_container_argv: list[str], + on_line: Callable[[str], None] | None = None, +) -> int: + """Run ``in_container_argv`` inside the running backend container, streamed. + + Wraps ``docker exec ``. Used to invoke provisioning + scripts already baked into the image (e.g. ``init-mobile-sdk.sh``) without + reimplementing them client-side — the in-image script stays the single source + of truth for what gets installed. Returns the process exit code; a stopped + container or missing docker CLI surfaces through the streamed output and a + non-zero code. + """ + argv = ["docker", "exec", backend_container_name(), *in_container_argv] + return await _stream_subprocess(argv, root, on_line) + + # --------------------------------------------------------------------------- # specflow-init.sh wrapper # --------------------------------------------------------------------------- diff --git a/mcp_server/tests/test_extra_deps.py b/mcp_server/tests/test_extra_deps.py new file mode 100644 index 0000000..0f7322e --- /dev/null +++ b/mcp_server/tests/test_extra_deps.py @@ -0,0 +1,47 @@ +"""Tests for the extra-dependency script catalog (tui.extra_deps).""" + +from tui.extra_deps import ( + EXTRA_DEPENDENCY_SCRIPTS, + MOBILE_SDK_SCRIPT, + DependencyComponent, + ExtraDependencyScript, + script_by_key, +) + + +def test_version_summary_joins_components(): + script = ExtraDependencyScript( + key="demo", + title="Demo", + description="d", + components=( + DependencyComponent("Foo", "1.2"), + DependencyComponent("Bar", "3.4"), + ), + container_path="/usr/local/bin/x.sh", + ) + assert script.version_summary == "Foo 1.2, Bar 3.4" + + +def test_command_is_sh_plus_container_path(): + assert MOBILE_SDK_SCRIPT.command() == ["sh", "/usr/local/bin/init-mobile-sdk.sh"] + + +def test_mobile_sdk_advertises_android_and_flutter(): + summary = MOBILE_SDK_SCRIPT.version_summary + assert "Android SDK" in summary + assert "Flutter" in summary + + +def test_script_by_key_roundtrip(): + for script in EXTRA_DEPENDENCY_SCRIPTS: + assert script_by_key(script.key) is script + + +def test_script_by_key_unknown_returns_none(): + assert script_by_key("does-not-exist") is None + + +def test_keys_are_unique(): + keys = [script.key for script in EXTRA_DEPENDENCY_SCRIPTS] + assert len(keys) == len(set(keys)) diff --git a/mcp_server/tests/test_local_env.py b/mcp_server/tests/test_local_env.py index 5b80843..bb2704f 100644 --- a/mcp_server/tests/test_local_env.py +++ b/mcp_server/tests/test_local_env.py @@ -305,6 +305,30 @@ async def test_start_containers_argv(self, tmp_path): ] assert exec_mock.call_args.kwargs["cwd"] == str(tmp_path) + @pytest.mark.asyncio + async def test_exec_in_backend_wraps_docker_exec(self, tmp_path, monkeypatch): + # docker exec , streamed, cwd = root. + monkeypatch.setenv("SPECFLOW_BACKEND_CONTAINER", "specflow-backend-test") + captured: list[str] = [] + fake = _FakeProc([b"line 1\n"], code=0) + with patch( + "services.local_env.asyncio.create_subprocess_exec", + new=AsyncMock(return_value=fake), + ) as exec_mock: + rc = await local_env.exec_in_backend( + tmp_path, ["sh", "/usr/local/bin/init-mobile-sdk.sh"], on_line=captured.append + ) + assert rc == 0 + assert captured == ["line 1\n"] + assert list(exec_mock.call_args.args) == [ + "docker", + "exec", + "specflow-backend-test", + "sh", + "/usr/local/bin/init-mobile-sdk.sh", + ] + assert exec_mock.call_args.kwargs["cwd"] == str(tmp_path) + class TestRunCommand: """run_command runs against real child processes — the point is to prove the diff --git a/mcp_server/tests/test_tui_app.py b/mcp_server/tests/test_tui_app.py index 4962b6e..4f3bece 100644 --- a/mcp_server/tests/test_tui_app.py +++ b/mcp_server/tests/test_tui_app.py @@ -10,7 +10,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch -from textual.widgets import Input +from textual.widgets import Button, Input, RichLog import httpx import pytest @@ -801,6 +801,93 @@ async def test_partial_langfuse_save_is_blocked(self, tmp_path): ).read_text() +class TestExtraDependencies: + """The Settings → Extra Dependencies section lists provisioning scripts and + installs them by `docker exec`-ing the in-image script.""" + + @staticmethod + def _land_on_sessions(tmp_path): + a, b, c = _gate_ready() + return ( + a, + b, + c, + patch("tui.app.fetch_sessions", new=AsyncMock(return_value=[])), + patch.object(tui_app.ClientSetupScreen, "_probe_verifiable", new=AsyncMock()), + patch("tui.app.mcp_clients.is_any_client_connected", return_value=True), + ) + + @pytest.mark.asyncio + async def test_section_lists_script_with_versions_and_button(self, tmp_path): + a, b, c, d, e, f = self._land_on_sessions(tmp_path) + with a, b, c, d, e, f: + app = tui_app.SpecFlowTUI(root=tmp_path, generation_id=None, poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("s") + await pilot.pause() + from tui.extra_deps import MOBILE_SDK_SCRIPT + + button = app.screen.query_one(f"#extra-dep-{MOBILE_SDK_SCRIPT.key}", Button) + assert str(button.label) == "Install" + versions = " ".join( + str(s.render()) for s in app.screen.query(".extra-dep-versions").results() + ) + assert "Android SDK" in versions + assert "Flutter" in versions + + @pytest.mark.asyncio + async def test_install_click_runs_docker_exec(self, tmp_path): + a, b, c, d, e, f = self._land_on_sessions(tmp_path) + exec_mock = AsyncMock(return_value=0) + with ( + a, + b, + c, + d, + e, + f, + patch("tui.app.local_env.containers_running", return_value=True), + patch("tui.app.local_env.exec_in_backend", new=exec_mock), + ): + app = tui_app.SpecFlowTUI(root=tmp_path, generation_id=None, poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("s") + await pilot.pause() + from tui.extra_deps import MOBILE_SDK_SCRIPT + + # press() (not pilot.click) — the button sits below the fold of the + # scrolling settings form, so it isn't in the clickable viewport. + app.screen.query_one(f"#extra-dep-{MOBILE_SDK_SCRIPT.key}", Button).press() + await pilot.pause() + exec_mock.assert_awaited_once() + argv = exec_mock.await_args.args[1] + assert argv == MOBILE_SDK_SCRIPT.command() + log = app.screen.query_one("#extra-dep-log", RichLog) + assert log.display is True + + @pytest.mark.asyncio + async def test_install_refuses_when_containers_down(self, tmp_path): + # The gate needs containers up to reach Settings; the refusal is checked + # by flipping `containers_running` to False only around the Install click. + a, b, c, d, e, f = self._land_on_sessions(tmp_path) + exec_mock = AsyncMock(return_value=0) + with a, b, c, d, e, f, patch("tui.app.local_env.exec_in_backend", new=exec_mock): + app = tui_app.SpecFlowTUI(root=tmp_path, generation_id=None, poll_interval=999) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("s") + await pilot.pause() + from tui.extra_deps import MOBILE_SDK_SCRIPT + + with patch("tui.app.local_env.containers_running", return_value=False): + app.screen.query_one(f"#extra-dep-{MOBILE_SDK_SCRIPT.key}", Button).press() + await pilot.pause() + # Nothing exec'd because the stack is down. + exec_mock.assert_not_awaited() + + class TestQuitBinding: @pytest.mark.asyncio async def test_q_exits_dashboard(self): diff --git a/mcp_server/tui/app.py b/mcp_server/tui/app.py index de695e7..05be636 100644 --- a/mcp_server/tui/app.py +++ b/mcp_server/tui/app.py @@ -77,6 +77,7 @@ save_env_secrets, ) from tui.constants import CHECKPOINT_STEPS, DEFAULT_POLL_INTERVAL, STATUS_PILLS, TERMINAL_STATUSES +from tui.extra_deps import EXTRA_DEPENDENCY_SCRIPTS, ExtraDependencyScript, script_by_key from tui.poller import MilestoneTracker, fire_milestones, poll_once from tui.render import format_tokens from tui.stream import workspace_message_events @@ -1572,10 +1573,14 @@ async def _run(self) -> None: class SettingsScreen(Screen): """Editor for runtime settings (mcp-config.json) and secrets (.env). - Three sections: runtime settings (mcp-config.json), core secrets (.env), and - an Advanced section for optional LangFuse tracing (.env). All secret keys + Four sections: runtime settings (mcp-config.json), core secrets (.env), an + Advanced section for optional LangFuse tracing (.env), and Extra Dependencies + — one-click installers for heavy SDKs (e.g. mobile) that are provisioned into + the sandbox cache on demand rather than baked into the image. All secret keys share one row-builder and one save loop, so a new secret is added in exactly - one place (its key list) and inherits the masking + blank-means-keep rules. + one place (its key list) and inherits the masking + blank-means-keep rules; + each extra-dependency script is a single entry in ``EXTRA_DEPENDENCY_SCRIPTS`` + and inherits the row layout and docker-exec install flow here. """ BINDINGS = [ @@ -1616,6 +1621,19 @@ def compose(self) -> ComposeResult: classes="settings-section", ) yield from self._secret_rows(LANGFUSE_KEYS, secrets) + yield Static( + "Extra Dependencies (installed into the sandbox cache — " + "requires running containers)", + classes="settings-section", + ) + for script in EXTRA_DEPENDENCY_SCRIPTS: + with Horizontal(classes="extra-dep-row"): + yield Label(script.title, classes="extra-dep-title") + yield Static(script.version_summary, classes="extra-dep-versions") + yield Button("Install", id=f"extra-dep-{script.key}", variant="primary") + extra_log = RichLog(id="extra-dep-log", highlight=False, markup=False, wrap=True) + extra_log.display = False + yield extra_log yield Footer() def _secret_input(self, key: str) -> str: @@ -1669,6 +1687,49 @@ def action_save(self) -> None: def action_cancel(self) -> None: self.app.pop_screen() + _EXTRA_DEP_PREFIX = "extra-dep-" + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Route an Extra Dependencies "Install" click to its provisioning run.""" + button_id = event.button.id or "" + if not button_id.startswith(self._EXTRA_DEP_PREFIX): + return + script = script_by_key(button_id[len(self._EXTRA_DEP_PREFIX) :]) + if script is None: + return + # Not exclusive: a second script can install alongside this one; the button + # is disabled for the duration so the SAME script can't be double-launched. + self.run_worker(self._install_extra_dep(script, event.button)) + + async def _install_extra_dep(self, script: ExtraDependencyScript, button: Button) -> None: + log = self.query_one("#extra-dep-log", RichLog) + log.display = True + # The script runs inside the backend container via `docker exec`; if the + # stack is down there is nothing to exec into, so refuse early with a clear + # message rather than surfacing a raw docker error. + if not local_env.containers_running(self.app.root): + log.write("The SpecFlow containers aren't running — start them first, then retry.\n") + self.notify( + "Start the SpecFlow containers before installing dependencies.", + severity="error", + ) + return + button.disabled = True + log.write(f"Installing {script.title} ({script.version_summary})…\n") + log.write("Already-provisioned components are skipped; first run can take several minutes.\n") + try: + rc = await local_env.exec_in_backend( + self.app.root, script.command(), on_line=log.write + ) + finally: + button.disabled = False + if rc == 0: + log.write(f"\n✔ {script.title} installed.\n") + self.notify(f"{script.title} installed.", severity="information") + else: + log.write(f"\n✗ {script.title} install failed (exit {rc}) — see the log above.\n") + self.notify(f"{script.title} install failed — see the log.", severity="error") + # --------------------------------------------------------------------------- # App @@ -1685,6 +1746,10 @@ class SpecFlowTUI(App): .settings-section { padding: 1 2 0 2; text-style: bold; color: $accent; } .settings-row { height: 3; padding: 0 2; } .settings-label { width: 20; content-align: left middle; } + .extra-dep-row { height: 3; padding: 0 2; } + .extra-dep-title { width: 20; content-align: left middle; text-style: bold; } + .extra-dep-versions { width: 1fr; content-align: left middle; color: $text-muted; } + #extra-dep-log { height: 10; border: round $primary; margin: 1 2; } #ws-stream { height: 2fr; border: round $primary; padding: 0 1; } #ws-stats { height: 1fr; padding: 0 1; } #onboard-body { height: 1fr; padding: 0 2; } diff --git a/mcp_server/tui/extra_deps.py b/mcp_server/tui/extra_deps.py new file mode 100644 index 0000000..289fffa --- /dev/null +++ b/mcp_server/tui/extra_deps.py @@ -0,0 +1,87 @@ +"""Catalog of optional "extra dependency" provisioning scripts. + +Small runtimes (Node/npm, JDK, Gradle, Kotlin) are baked into the backend Docker +image, but the heavy mobile SDKs (Android SDK, Flutter — which bundles Dart) are +impractical to bake and are provisioned on demand into the shared NFS cache by +``backend/scripts/init-mobile-sdk.sh`` (copied to ``/usr/local/bin`` in the image). + +A user who forgets that step finds the agents missing those SDKs mid-run. This +module models each such script as data so the Settings screen can list it with a +one-click install; adding another provisioning script is a single entry here +(Open/Closed) and inherits the render + docker-exec plumbing. + +The version strings are display-only and MIRROR the env-overridable defaults in +``backend/scripts/init-mobile-sdk.sh`` (and ``backend/Dockerfile``), which remain +the single source of truth for what actually gets installed. This client is a +separate deployable and cannot import the backend script; a drift here only +mislabels the button, never changes what the script installs. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DependencyComponent: + """One installable piece advertised by a script (name + display version).""" + + name: str + version: str + + def __str__(self) -> str: + return f"{self.name} {self.version}" + + +@dataclass(frozen=True) +class ExtraDependencyScript: + """An idempotent provisioning script baked into the backend container. + + ``container_path`` is the absolute path of the script inside the running + backend container; ``command`` returns the argv to run it there (the caller + prepends ``docker exec ``). The script is idempotent, so re-running + an already-provisioned component is a fast no-op. + """ + + key: str + title: str + description: str + components: tuple[DependencyComponent, ...] + container_path: str + + @property + def version_summary(self) -> str: + """Comma-joined "Name version" list shown next to the title.""" + return ", ".join(str(component) for component in self.components) + + def command(self) -> list[str]: + """In-container argv that runs this script (interpreter + path).""" + return ["sh", self.container_path] + + +# The mobile SDK provisioner — the one script we currently expose. Versions mirror +# init-mobile-sdk.sh: ANDROID_SDK_CMDLINE_TOOLS_VERSION and FLUTTER_VERSION. +MOBILE_SDK_SCRIPT = ExtraDependencyScript( + key="mobile-sdk", + title="Mobile SDKs", + description=( + "Android SDK, Flutter, and the bundled Dart SDK for mobile projects " + "(installed into the shared sandbox cache)." + ), + components=( + DependencyComponent("Android SDK", "cmdline-tools 11076708, platforms 34–36"), + DependencyComponent("Flutter", "3.27.4 (bundles Dart)"), + ), + container_path="/usr/local/bin/init-mobile-sdk.sh", +) + +# Every script offered in Settings → Extra Dependencies. Append to extend. +EXTRA_DEPENDENCY_SCRIPTS: tuple[ExtraDependencyScript, ...] = (MOBILE_SDK_SCRIPT,) + + +def script_by_key(key: str) -> ExtraDependencyScript | None: + """Look up a script by its stable ``key`` (used to map widget ids back).""" + for script in EXTRA_DEPENDENCY_SCRIPTS: + if script.key == key: + return script + return None