Coverage for src/ai_shell/expo.py: 83%
183 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-03 01:05 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-03 01:05 +0000
1"""Expo dev server integration for dev containers.
3``ai-shell claude --expo`` (and the same flag on the other tool commands)
4starts ``npx expo start --tunnel`` inside the per-project dev container and
5prints the tunnel URL as a QR code *before* the agent takes over the terminal,
6so a phone can be pointed at the running app up front. Auto-detection starts
7it for any directory that actually looks like an Expo app, which is the common
8case for these repos.
10Why the tunnel is the default — and the only mode implemented:
12* ngrok dials **outbound** from the container to Expo's ngrok edge, so the
13 phone reaches ``https://<subdomain>.exp.direct`` without any published port.
14 ai-shell's dev ports are hash-assigned into 10000-39999 rather than mapped
15 identity, so a LAN URL would advertise the container's own ``:8081`` and be
16 unreachable; tunnelling sidesteps the whole problem.
17* No Expo account is needed. ``@expo/cli`` connects with **its own** ngrok
18 auth token and builds the hostname as
19 ``{randomness}-{username}-{port}.exp.direct``, where the username falls back
20 to ``anonymous`` when nobody is logged in. Logging in only matters for EAS
21 (``build``/``update``/``submit``).
22* ``randomness`` is persisted in ``.expo/settings.json`` *in the project
23 directory*, which is bind-mounted, so the tunnel URL — and therefore the QR
24 code — is stable across restarts and container recreations. Nothing needs
25 to be pinned with ``EXPO_TUNNEL_SUBDOMAIN``.
27One sharp edge worth knowing: if ``EXPO_TOKEN`` holds a **robot** token, the
28Expo CLI refuses to open a tunnel at all (``NGROK_ROBOT``). That failure is
29detected in the server log and reported with the fix.
31Like the T3 server, the dev server is started detached and outlives the tool
32session — the terminal goes away, the app on the phone keeps reloading.
33"""
35from __future__ import annotations
37import json
38import logging
39import re
40import shlex
41import subprocess
42import time
43from dataclasses import dataclass
44from pathlib import Path
45from typing import TYPE_CHECKING
47from rich.console import Console
49from ai_shell.defaults import EXPO_METRO_PORT
50from ai_shell.qr import render_qr
52if TYPE_CHECKING:
53 from ai_shell.config import AiShellConfig
54 from ai_shell.container import ContainerManager
56logger = logging.getLogger(__name__)
57console = Console(stderr=True)
59#: npm package providing the ngrok binding the Expo CLI loads for tunnels.
60#: ``NgrokResolver`` prefers a global install and does not prompt for one on a
61#: non-TTY, so it has to be present before ``expo start --tunnel`` runs.
62EXPO_NGROK_PACKAGE = "@expo/ngrok@^4.1.0"
64#: Where the detached dev server's stdout/stderr lands inside the container.
65EXPO_LOG_PATH = "/var/log/ai-shell/expo-start.log"
67#: How long to wait for the tunnel URL to show up in the log. ngrok
68#: negotiation on a cold start is the slow part, not Metro.
69EXPO_READY_TIMEOUT = 180.0
71#: npm install can be slow on a cold npm cache volume.
72EXPO_INSTALL_TIMEOUT = 900
74#: App-config filenames that mark a directory as an Expo app. ``app.json`` is
75#: only counted when it actually carries an ``expo`` key — plenty of unrelated
76#: projects ship an ``app.json``.
77_APP_CONFIG_FILES = (
78 "app.config.ts",
79 "app.config.js",
80 "app.config.mjs",
81 "app.config.cjs",
82 "app.config.json",
83)
85#: The tunnel URL as the Expo CLI prints it. Both schemes are matched because
86#: the CLI logs the ``exp://`` deep link and the ``https://`` origin.
87_TUNNEL_URL_RE = re.compile(r"\b(?:exp|https)://[A-Za-z0-9._-]+\.exp\.direct(?::\d+)?\b")
90class ExpoError(Exception):
91 """The Expo dev server could not be started inside the dev container."""
94@dataclass(frozen=True)
95class ExpoProject:
96 """What the host filesystem says about the project at *project_dir*."""
98 project_dir: Path
99 has_dependency: bool
100 app_config: str | None
102 @property
103 def is_app(self) -> bool:
104 """True when this looks like a real Expo app, not just an expo dep.
106 Both signals are required for *auto-detection*: an ``expo`` dependency
107 alone also describes Expo libraries and config plugins, which have
108 nothing to serve. ``--expo`` only requires the dependency.
109 """
110 return self.has_dependency and self.app_config is not None
113def detect(project_dir: Path | str) -> ExpoProject:
114 """Inspect *project_dir* for the two Expo app signals.
116 A directory that cannot be inspected at all is simply not an Expo app —
117 detection never fails a launch.
118 """
119 try:
120 project_dir = Path(project_dir)
121 except TypeError:
122 logger.debug("Not a usable project directory: %r", project_dir)
123 return ExpoProject(project_dir=Path(), has_dependency=False, app_config=None)
125 package_json = project_dir / "package.json"
126 has_dependency = False
127 if package_json.is_file():
128 try:
129 data = json.loads(package_json.read_text(encoding="utf-8"))
130 except (OSError, ValueError) as exc:
131 logger.debug("Could not read %s: %s", package_json, exc)
132 data = {}
133 if isinstance(data, dict):
134 for section in ("dependencies", "devDependencies"):
135 deps = data.get(section)
136 if isinstance(deps, dict) and "expo" in deps:
137 has_dependency = True
138 break
140 app_config: str | None = None
141 app_json = project_dir / "app.json"
142 if app_json.is_file():
143 try:
144 parsed = json.loads(app_json.read_text(encoding="utf-8"))
145 except (OSError, ValueError) as exc:
146 logger.debug("Could not read %s: %s", app_json, exc)
147 parsed = None
148 if isinstance(parsed, dict) and "expo" in parsed:
149 app_config = "app.json"
150 if app_config is None:
151 for name in _APP_CONFIG_FILES:
152 if (project_dir / name).is_file():
153 app_config = name
154 break
156 return ExpoProject(
157 project_dir=project_dir,
158 has_dependency=has_dependency,
159 app_config=app_config,
160 )
163def _exec(
164 container_name: str,
165 args: list[str],
166 *,
167 extra_env: dict[str, str] | None = None,
168 timeout: float | None = 60,
169) -> subprocess.CompletedProcess[str]:
170 """Run a command in *container_name* and capture its output."""
171 cmd = ["docker", "exec"]
172 for key, value in (extra_env or {}).items():
173 cmd.extend(["-e", f"{key}={value}"])
174 cmd.append(container_name)
175 cmd.extend(args)
176 logger.debug("expo exec: %s", " ".join(args))
177 return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
180def _login_shell(script: str) -> list[str]:
181 """Wrap *script* so it runs with the container's login PATH."""
182 return ["bash", "-lc", script]
185def dependency_installed(container_name: str, workdir: str) -> bool:
186 """True when ``expo`` resolves from *workdir* inside the container.
188 The check has to run in the container: ``node_modules`` is overlaid with a
189 per-project named volume, so the host's copy says nothing about what the
190 container can actually resolve. Node resolution (rather than a
191 ``node_modules/expo`` stat) also covers monorepos that hoist.
192 """
193 script = f"cd {shlex.quote(workdir)} && node -e \"require.resolve('expo/package.json')\""
194 return _exec(container_name, _login_shell(script), timeout=60).returncode == 0
197def ensure_ngrok(container_name: str) -> None:
198 """Install ``@expo/ngrok`` globally if it is not already there.
200 The Expo CLI resolves it with ``prefersGlobalInstall`` and, on the second
201 pass, with prompting and auto-install both disabled — so without this the
202 tunnel just asserts on a detached (non-TTY) start.
203 """
204 probe = 'test -d "$(npm root -g)/@expo/ngrok"'
205 if _exec(container_name, _login_shell(probe), timeout=60).returncode == 0:
206 return
208 console.print(
209 f"[dim]Installing the Expo tunnel dependency (npm install -g {EXPO_NGROK_PACKAGE})...[/dim]"
210 )
211 result = _exec(
212 container_name,
213 _login_shell(f"npm install -g {shlex.quote(EXPO_NGROK_PACKAGE)}"),
214 timeout=EXPO_INSTALL_TIMEOUT,
215 )
216 if result.returncode != 0:
217 raise ExpoError(
218 "Failed to install @expo/ngrok in the dev container, so the tunnel "
219 "cannot be opened.\n"
220 f" npm said: {(result.stderr or result.stdout).strip()[-500:]}"
221 )
224def server_running(container_name: str) -> bool:
225 """True when an Expo dev server process is already up in the container."""
226 result = _exec(container_name, ["pgrep", "-f", "expo start"], timeout=15)
227 return result.returncode == 0
230def server_log(container_name: str, lines: int = 200) -> str:
231 """Return the tail of the detached server's log."""
232 result = _exec(
233 container_name,
234 _login_shell(f"tail -n {lines} {shlex.quote(EXPO_LOG_PATH)} 2>/dev/null || true"),
235 timeout=15,
236 )
237 return (result.stdout or "").strip()
240def tunnel_url(container_name: str) -> str | None:
241 """Most recent tunnel URL in the server log, normalized to ``exp://``.
243 Expo Go and dev clients open the ``exp://`` deep link; the ``https://``
244 origin in the log is the same host.
245 """
246 matches: list[str] = _TUNNEL_URL_RE.findall(server_log(container_name))
247 if not matches:
248 return None
249 url = matches[-1]
250 if url.startswith("https://"):
251 url = "exp://" + url[len("https://") :]
252 return url
255def start_server(
256 container_name: str,
257 workdir: str,
258 *,
259 port: int = EXPO_METRO_PORT,
260 extra_env: dict[str, str] | None = None,
261) -> None:
262 """Start ``expo start --tunnel`` detached inside the container.
264 The log is truncated on each fresh start so :func:`tunnel_url` can never
265 hand back a URL from a previous run's tunnel.
266 """
267 inner = (
268 f"mkdir -p $(dirname {shlex.quote(EXPO_LOG_PATH)}) && "
269 f"cd {shlex.quote(workdir)} && "
270 f"exec npx expo start --tunnel --port {port} "
271 f"</dev/null >{shlex.quote(EXPO_LOG_PATH)} 2>&1"
272 )
273 env = dict(extra_env or {})
274 # We render our own QR from the parsed URL, so the CLI's copy is just log
275 # noise that the URL regex would have to scan past.
276 env["EXPO_NO_QR_CODE"] = "1"
277 env["BROWSER"] = "none"
279 cmd = ["docker", "exec", "-d"]
280 for key, value in env.items():
281 cmd.extend(["-e", f"{key}={value}"])
282 cmd.append(container_name)
283 cmd.extend(_login_shell(inner))
284 logger.debug("expo start: %s", inner)
285 subprocess.run(cmd, check=True, capture_output=True)
288def wait_for_tunnel(
289 container_name: str,
290 *,
291 timeout: float = EXPO_READY_TIMEOUT,
292) -> str | None:
293 """Poll the log until the tunnel URL appears, or *timeout* elapses."""
294 deadline = time.monotonic() + timeout
295 while time.monotonic() < deadline:
296 url = tunnel_url(container_name)
297 if url:
298 return url
299 time.sleep(1.0)
300 return None
303def _startup_failure(log: str) -> str | None:
304 """Translate a known fatal log signature into an actionable message."""
305 if "NGROK_ROBOT" in log or "Cannot use ngrok with a robot user" in log:
306 return (
307 "EXPO_TOKEN is a robot token, and the Expo CLI refuses to open a "
308 "tunnel for robot users.\n"
309 " Use a personal access token, or unset EXPO_TOKEN for this project."
310 )
311 if "@expo/ngrok" in log and "install" in log.lower():
312 return (
313 "The Expo CLI could not load @expo/ngrok.\n"
314 " Install it in the container: run 'ai-shell shell' and then "
315 "'npm install -g @expo/ngrok'."
316 )
317 return None
320def attach(
321 manager: ContainerManager,
322 container_name: str,
323 config: AiShellConfig,
324 exec_env: dict[str, str] | None = None,
325 *,
326 port: int = EXPO_METRO_PORT,
327 explicit: bool = False,
328) -> None:
329 """Start (or reuse) the Expo dev server and print the tunnel URL/QR.
331 *explicit* marks a user-requested ``--expo``: preconditions that are
332 silently skipped during auto-detection become hard errors instead, because
333 the user asked for something that cannot be delivered.
335 Idempotent: against a live server this just re-prints the QR, which is how
336 a second device gets pointed at the same app.
337 """
338 project = detect(config.project_dir)
340 if not project.has_dependency:
341 if explicit:
342 raise ExpoError(
343 f"{config.project_dir} has no 'expo' dependency in package.json, "
344 "so there is no Expo app to start."
345 )
346 return
347 if not project.is_app and not explicit:
348 logger.debug("Expo dependency without an app config in %s; skipping", config.project_dir)
349 return
351 workdir = f"/root/projects/{config.project_name}"
353 if not dependency_installed(container_name, workdir):
354 message = (
355 "Expo is in package.json but is not installed in the dev container "
356 "(node_modules is a container-local volume, not the host's).\n"
357 " Install inside the container first: run 'ai-shell shell' "
358 "and then 'npm install'."
359 )
360 if explicit:
361 raise ExpoError(message)
362 console.print(f"[yellow]Skipping the Expo dev server: {message}[/yellow]")
363 return
365 reused = server_running(container_name)
366 if reused:
367 console.print(f"[dim]Expo dev server already running in {container_name}.[/dim]")
368 url = tunnel_url(container_name)
369 else:
370 ensure_ngrok(container_name)
371 console.print(f"[bold]Starting the Expo dev server in {container_name}...[/bold]")
372 start_server(container_name, workdir, port=port, extra_env=exec_env)
373 with console.status("[bold]Opening the Expo tunnel...[/bold]", spinner="dots"):
374 url = wait_for_tunnel(container_name)
376 if url is None:
377 log = server_log(container_name)
378 if reused:
379 # Something is serving, but no tunnel URL was ever logged — most
380 # likely an `expo start` someone launched by hand without --tunnel.
381 reason = (
382 "An Expo dev server is already running in the container but has "
383 "no tunnel URL, so there is nothing a phone can reach.\n"
384 " Stop it (pkill -f 'expo start' in the container) and rerun to "
385 "get a tunnelled one."
386 )
387 else:
388 reason = (
389 _startup_failure(log)
390 or f"The Expo tunnel did not come up within {int(EXPO_READY_TIMEOUT)}s."
391 )
392 raise ExpoError(reason + f"\n Log ({EXPO_LOG_PATH}):\n{log[-1500:] or ' (empty)'}")
394 host_port = _published_host_port(manager, container_name, port)
396 console.print("[bold]Expo[/bold]")
397 console.print(f" [dim]Project:[/dim] {config.project_name} [dim]({workdir})[/dim]")
398 console.print(f" [green bold]Scan:[/green bold] {url}")
399 if host_port is not None:
400 console.print(
401 f" [dim]Metro:[/dim] http://localhost:{host_port} [dim](this machine)[/dim]"
402 )
403 console.print(f" [dim]Log:[/dim] docker exec {container_name} tail -f {EXPO_LOG_PATH}")
405 qr = render_qr(url)
406 if qr:
407 console.print()
408 console.print(qr)
409 console.print()
412def _published_host_port(manager: ContainerManager, container_name: str, port: int) -> int | None:
413 """Host port bound to *port*, read from the live container."""
414 from docker.errors import DockerException
416 try:
417 ports = manager.container_ports(container_name)
418 except DockerException:
419 return None
420 binding = (ports or {}).get(f"{port}/tcp")
421 if not binding:
422 return None
423 try:
424 return int(binding.rsplit(":", 1)[1])
425 except (IndexError, ValueError):
426 return None