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

1"""Expo dev server integration for dev containers. 

2 

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. 

9 

10Why the tunnel is the default — and the only mode implemented: 

11 

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``. 

26 

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. 

30 

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""" 

34 

35from __future__ import annotations 

36 

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 

46 

47from rich.console import Console 

48 

49from ai_shell.defaults import EXPO_METRO_PORT 

50from ai_shell.qr import render_qr 

51 

52if TYPE_CHECKING: 

53 from ai_shell.config import AiShellConfig 

54 from ai_shell.container import ContainerManager 

55 

56logger = logging.getLogger(__name__) 

57console = Console(stderr=True) 

58 

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" 

63 

64#: Where the detached dev server's stdout/stderr lands inside the container. 

65EXPO_LOG_PATH = "/var/log/ai-shell/expo-start.log" 

66 

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 

70 

71#: npm install can be slow on a cold npm cache volume. 

72EXPO_INSTALL_TIMEOUT = 900 

73 

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) 

84 

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") 

88 

89 

90class ExpoError(Exception): 

91 """The Expo dev server could not be started inside the dev container.""" 

92 

93 

94@dataclass(frozen=True) 

95class ExpoProject: 

96 """What the host filesystem says about the project at *project_dir*.""" 

97 

98 project_dir: Path 

99 has_dependency: bool 

100 app_config: str | None 

101 

102 @property 

103 def is_app(self) -> bool: 

104 """True when this looks like a real Expo app, not just an expo dep. 

105 

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 

111 

112 

113def detect(project_dir: Path | str) -> ExpoProject: 

114 """Inspect *project_dir* for the two Expo app signals. 

115 

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) 

124 

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 

139 

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 

155 

156 return ExpoProject( 

157 project_dir=project_dir, 

158 has_dependency=has_dependency, 

159 app_config=app_config, 

160 ) 

161 

162 

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) 

178 

179 

180def _login_shell(script: str) -> list[str]: 

181 """Wrap *script* so it runs with the container's login PATH.""" 

182 return ["bash", "-lc", script] 

183 

184 

185def dependency_installed(container_name: str, workdir: str) -> bool: 

186 """True when ``expo`` resolves from *workdir* inside the container. 

187 

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 

195 

196 

197def ensure_ngrok(container_name: str) -> None: 

198 """Install ``@expo/ngrok`` globally if it is not already there. 

199 

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 

207 

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 ) 

222 

223 

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 

228 

229 

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() 

238 

239 

240def tunnel_url(container_name: str) -> str | None: 

241 """Most recent tunnel URL in the server log, normalized to ``exp://``. 

242 

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 

253 

254 

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. 

263 

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" 

278 

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) 

286 

287 

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 

301 

302 

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 

318 

319 

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. 

330 

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. 

334 

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) 

339 

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 

350 

351 workdir = f"/root/projects/{config.project_name}" 

352 

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 

364 

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) 

375 

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)'}") 

393 

394 host_port = _published_host_port(manager, container_name, port) 

395 

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}") 

404 

405 qr = render_qr(url) 

406 if qr: 

407 console.print() 

408 console.print(qr) 

409 console.print() 

410 

411 

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 

415 

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