@qubiqlabs/mobiflow 0.9.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +9 -11
  2. package/bin/mobiflow.js +73 -28
  3. package/package.json +8 -3
  4. package/pyproject.toml +59 -0
  5. package/src/mobiflow/__init__.py +9 -0
  6. package/src/mobiflow/__main__.py +6 -0
  7. package/src/mobiflow/baseline.py +228 -0
  8. package/src/mobiflow/casedata.py +159 -0
  9. package/src/mobiflow/cases/__init__.py +715 -0
  10. package/src/mobiflow/cli.py +1423 -0
  11. package/src/mobiflow/cloud/__init__.py +28 -0
  12. package/src/mobiflow/cloud/base.py +272 -0
  13. package/src/mobiflow/cloud/browserstack.py +330 -0
  14. package/src/mobiflow/cloud/maestro_cloud.py +141 -0
  15. package/src/mobiflow/cloud/media.py +269 -0
  16. package/src/mobiflow/cloud/runner.py +156 -0
  17. package/src/mobiflow/cloud/testmu.py +378 -0
  18. package/src/mobiflow/config/__init__.py +538 -0
  19. package/src/mobiflow/deps.py +377 -0
  20. package/src/mobiflow/devices.py +717 -0
  21. package/src/mobiflow/explore.py +623 -0
  22. package/src/mobiflow/incremental.py +198 -0
  23. package/src/mobiflow/init/__init__.py +794 -0
  24. package/src/mobiflow/llm.py +462 -0
  25. package/src/mobiflow/llm_catalog.py +232 -0
  26. package/src/mobiflow/maestro/__init__.py +1506 -0
  27. package/src/mobiflow/maestro/lifecycle.py +279 -0
  28. package/src/mobiflow/pipeline.py +600 -0
  29. package/src/mobiflow/report/__init__.py +617 -0
  30. package/src/mobiflow/report/static/favicon.jpg +0 -0
  31. package/src/mobiflow/report/static/favicon.svg +1 -0
  32. package/src/mobiflow/report/static/icons.svg +24 -0
  33. package/src/mobiflow/report/static/index.html +99 -0
  34. package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
  35. package/src/mobiflow/reporting.py +682 -0
  36. package/src/mobiflow/sample_apps.py +259 -0
  37. package/src/mobiflow/secrets.py +90 -0
  38. package/src/mobiflow/selectors.py +128 -0
  39. package/src/mobiflow/suite.py +263 -0
@@ -0,0 +1,377 @@
1
+ """Detect and auto-install missing runtime packages / tools.
2
+
3
+ Installable during ``mobiflow init`` / ``mobiflow setup``:
4
+
5
+ - Python extras: ``openai``, optional ``anthropic``
6
+ - Maestro CLI (official curl installer)
7
+ - JDK via Homebrew ``openjdk`` when ``brew`` is available
8
+
9
+ Not auto-installed (reported only): Android ``adb`` / full SDK, Xcode simctl.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import os
16
+ import platform
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Callable, Optional
23
+
24
+ PrintFn = Callable[[str], None]
25
+
26
+
27
+ @dataclass
28
+ class DepStatus:
29
+ id: str
30
+ label: str
31
+ ok: bool
32
+ detail: str = ""
33
+ installable: bool = False
34
+ required: bool = True
35
+
36
+
37
+ @dataclass
38
+ class SetupReport:
39
+ items: list[DepStatus] = field(default_factory=list)
40
+ actions: list[str] = field(default_factory=list)
41
+ errors: list[str] = field(default_factory=list)
42
+
43
+ @property
44
+ def missing(self) -> list[DepStatus]:
45
+ return [i for i in self.items if not i.ok]
46
+
47
+ @property
48
+ def missing_installable(self) -> list[DepStatus]:
49
+ return [i for i in self.missing if i.installable]
50
+
51
+ @property
52
+ def missing_manual(self) -> list[DepStatus]:
53
+ return [i for i in self.missing if not i.installable and i.required]
54
+
55
+
56
+ def _has_module(name: str) -> bool:
57
+ try:
58
+ return importlib.util.find_spec(name) is not None
59
+ except (ModuleNotFoundError, ValueError, AttributeError):
60
+ return False
61
+
62
+
63
+ def _which(name: str) -> Optional[str]:
64
+ return shutil.which(name)
65
+
66
+
67
+ def _maestro_binary() -> Optional[str]:
68
+ from mobiflow.maestro import resolve_maestro_binary
69
+
70
+ return resolve_maestro_binary()
71
+
72
+
73
+ def _java_home() -> Optional[str]:
74
+ from mobiflow.maestro import resolve_java_home
75
+
76
+ return resolve_java_home()
77
+
78
+
79
+ def _adb_binary() -> Optional[str]:
80
+ adb = _which("adb")
81
+ if adb:
82
+ return adb
83
+ for candidate in (
84
+ Path.home() / "Library/Android/sdk/platform-tools/adb",
85
+ Path(os.environ.get("ANDROID_HOME") or "") / "platform-tools" / "adb",
86
+ Path(os.environ.get("ANDROID_SDK_ROOT") or "") / "platform-tools" / "adb",
87
+ ):
88
+ if candidate.is_file() and os.access(candidate, os.X_OK):
89
+ return str(candidate)
90
+ return None
91
+
92
+
93
+ def probe_dependencies(*, want_anthropic: bool = False) -> list[DepStatus]:
94
+ """Return status for each known dependency."""
95
+ items: list[DepStatus] = []
96
+
97
+ # Python core (should already be present if mobiflow is installed)
98
+ for mod, label, required in (
99
+ ("openai", "Python package: openai", True),
100
+ ("httpx", "Python package: httpx", True),
101
+ ("yaml", "Python package: pyyaml", True),
102
+ ("pydantic", "Python package: pydantic", True),
103
+ ("click", "Python package: click", True),
104
+ ("rich", "Python package: rich", True),
105
+ ("questionary", "Python package: questionary", True),
106
+ ):
107
+ ok = _has_module(mod)
108
+ items.append(
109
+ DepStatus(
110
+ id=f"py:{mod}",
111
+ label=label,
112
+ ok=ok,
113
+ detail="importable" if ok else "missing — will pip install",
114
+ installable=True,
115
+ required=required,
116
+ )
117
+ )
118
+
119
+ anth_ok = _has_module("anthropic")
120
+ if anth_ok:
121
+ anth_detail = "importable"
122
+ elif want_anthropic:
123
+ anth_detail = "missing — needed for Anthropic profiles"
124
+ else:
125
+ anth_detail = "optional — install if using Anthropic codegen"
126
+ items.append(
127
+ DepStatus(
128
+ id="py:anthropic",
129
+ label="Python package: anthropic",
130
+ ok=anth_ok,
131
+ detail=anth_detail,
132
+ installable=True,
133
+ required=want_anthropic,
134
+ )
135
+ )
136
+
137
+ maestro = _maestro_binary()
138
+ items.append(
139
+ DepStatus(
140
+ id="maestro",
141
+ label="Maestro CLI",
142
+ ok=bool(maestro),
143
+ detail=maestro or "not found — install via get.maestro.mobile.dev",
144
+ installable=True,
145
+ required=True,
146
+ )
147
+ )
148
+
149
+ jh = _java_home()
150
+ java_bin = _which("java")
151
+ java_ok = bool(jh or java_bin)
152
+ items.append(
153
+ DepStatus(
154
+ id="java",
155
+ label="JDK (JAVA_HOME / java)",
156
+ ok=java_ok,
157
+ detail=jh or java_bin or "not found — Maestro needs a JDK",
158
+ installable=bool(_which("brew")) or platform.system() == "Darwin",
159
+ required=True,
160
+ )
161
+ )
162
+
163
+ adb = _adb_binary()
164
+ items.append(
165
+ DepStatus(
166
+ id="adb",
167
+ label="Android adb (optional)",
168
+ ok=bool(adb),
169
+ detail=adb or "not found — needed for Android emulators/devices",
170
+ installable=bool(_which("brew")),
171
+ required=False,
172
+ )
173
+ )
174
+
175
+ if platform.system() == "Darwin":
176
+ xcrun = _which("xcrun")
177
+ items.append(
178
+ DepStatus(
179
+ id="xcrun",
180
+ label="Xcode xcrun / simctl (optional)",
181
+ ok=bool(xcrun),
182
+ detail=xcrun or "not found — needed for iOS Simulator",
183
+ installable=False,
184
+ required=False,
185
+ )
186
+ )
187
+
188
+ return items
189
+
190
+
191
+ def _pip_install(*packages: str, log: PrintFn) -> bool:
192
+ if not packages:
193
+ return True
194
+ cmd = [
195
+ sys.executable,
196
+ "-m",
197
+ "pip",
198
+ "install",
199
+ "--upgrade",
200
+ "--prefer-binary",
201
+ *packages,
202
+ ]
203
+ log(f" $ {' '.join(cmd)}")
204
+ proc = subprocess.run(cmd, text=True)
205
+ return proc.returncode == 0
206
+
207
+
208
+ def _install_maestro(*, log: PrintFn) -> bool:
209
+ if _maestro_binary():
210
+ return True
211
+ if not _which("curl"):
212
+ log(" curl not found — cannot download Maestro installer.")
213
+ return False
214
+ log(" Installing Maestro CLI (https://get.maestro.mobile.dev)…")
215
+ # Official installer; non-interactive
216
+ cmd = "curl -Ls 'https://get.maestro.mobile.dev' | bash"
217
+ log(f" $ {cmd}")
218
+ proc = subprocess.run(cmd, shell=True, text=True)
219
+ if proc.returncode != 0:
220
+ return False
221
+ # Ensure ~/.maestro/bin is discoverable in this process
222
+ home_bin = Path.home() / ".maestro" / "bin"
223
+ if home_bin.is_dir():
224
+ os.environ["PATH"] = str(home_bin) + os.pathsep + os.environ.get("PATH", "")
225
+ return bool(_maestro_binary())
226
+
227
+
228
+ def _install_java(*, log: PrintFn) -> bool:
229
+ if _java_home() or _which("java"):
230
+ return True
231
+ brew = _which("brew")
232
+ if not brew:
233
+ log(" Homebrew not found — install a JDK manually, then set JAVA_HOME.")
234
+ return False
235
+ log(" Installing OpenJDK via Homebrew…")
236
+ cmd = [brew, "install", "openjdk"]
237
+ log(f" $ {' '.join(cmd)}")
238
+ proc = subprocess.run(cmd, text=True)
239
+ if proc.returncode != 0:
240
+ return False
241
+ # Common brew link path
242
+ for candidate in (
243
+ "/opt/homebrew/opt/openjdk",
244
+ "/usr/local/opt/openjdk",
245
+ ):
246
+ if Path(candidate).is_dir():
247
+ os.environ.setdefault("JAVA_HOME", candidate)
248
+ os.environ["PATH"] = str(Path(candidate) / "bin") + os.pathsep + os.environ.get(
249
+ "PATH", ""
250
+ )
251
+ break
252
+ return bool(_java_home() or _which("java"))
253
+
254
+
255
+ def _install_adb(*, log: PrintFn) -> bool:
256
+ if _adb_binary():
257
+ return True
258
+ brew = _which("brew")
259
+ if not brew:
260
+ log(" Homebrew not found — install Android platform-tools manually.")
261
+ return False
262
+ log(" Installing Android platform-tools (adb) via Homebrew…")
263
+ cmd = [brew, "install", "android-platform-tools"]
264
+ log(f" $ {' '.join(cmd)}")
265
+ proc = subprocess.run(cmd, text=True)
266
+ return proc.returncode == 0 and bool(_adb_binary())
267
+
268
+
269
+ def install_missing(
270
+ *,
271
+ want_anthropic: bool = False,
272
+ install_adb: bool = False,
273
+ log: Optional[PrintFn] = None,
274
+ ) -> SetupReport:
275
+ """Probe then install anything missing that we can auto-fix."""
276
+ _log = log or (lambda m: print(m, file=sys.stderr))
277
+ report = SetupReport(items=probe_dependencies(want_anthropic=want_anthropic))
278
+
279
+ # Python packages
280
+ py_missing = [
281
+ i.id.removeprefix("py:")
282
+ for i in report.items
283
+ if i.id.startswith("py:") and not i.ok and i.installable
284
+ ]
285
+ # Map import name → pip name
286
+ pip_map = {"yaml": "pyyaml", "anthropic": "anthropic"}
287
+ to_pip = [pip_map.get(m, m) for m in py_missing]
288
+ # Always ensure openai present
289
+ if not _has_module("openai") and "openai" not in to_pip:
290
+ to_pip.append("openai")
291
+ if want_anthropic and not _has_module("anthropic") and "anthropic" not in to_pip:
292
+ to_pip.append("anthropic")
293
+
294
+ if to_pip:
295
+ _log("Installing missing Python packages…")
296
+ if _pip_install(*to_pip, log=_log):
297
+ report.actions.append(f"pip install {' '.join(to_pip)}")
298
+ else:
299
+ report.errors.append(f"pip install failed: {' '.join(to_pip)}")
300
+
301
+ # Maestro
302
+ maestro_item = next((i for i in report.items if i.id == "maestro"), None)
303
+ if maestro_item and not maestro_item.ok:
304
+ _log("Installing Maestro CLI…")
305
+ if _install_maestro(log=_log):
306
+ report.actions.append("Installed Maestro CLI → ~/.maestro/bin")
307
+ else:
308
+ report.errors.append(
309
+ "Maestro install failed. Manual: curl -Ls https://get.maestro.mobile.dev | bash"
310
+ )
311
+
312
+ # Java
313
+ java_item = next((i for i in report.items if i.id == "java"), None)
314
+ if java_item and not java_item.ok:
315
+ _log("Installing JDK…")
316
+ if _install_java(log=_log):
317
+ report.actions.append("Installed OpenJDK (Homebrew)")
318
+ else:
319
+ report.errors.append(
320
+ "JDK install failed. Install a JDK and export JAVA_HOME."
321
+ )
322
+
323
+ # adb (optional)
324
+ adb_item = next((i for i in report.items if i.id == "adb"), None)
325
+ if install_adb and adb_item and not adb_item.ok and adb_item.installable:
326
+ _log("Installing Android platform-tools…")
327
+ if _install_adb(log=_log):
328
+ report.actions.append("Installed android-platform-tools (adb)")
329
+ else:
330
+ report.errors.append("adb install failed.")
331
+
332
+ # Re-probe
333
+ report.items = probe_dependencies(want_anthropic=want_anthropic)
334
+ return report
335
+
336
+
337
+ def ensure_runtime_deps(
338
+ *,
339
+ auto_install: bool = True,
340
+ want_anthropic: bool = False,
341
+ log: Optional[PrintFn] = None,
342
+ ) -> SetupReport:
343
+ """Ensure required tools exist; optionally auto-install missing ones."""
344
+ _log = log or (lambda m: print(m, file=sys.stderr))
345
+ items = probe_dependencies(want_anthropic=want_anthropic)
346
+ missing_req = [i for i in items if not i.ok and i.required]
347
+ if not missing_req:
348
+ return SetupReport(items=items)
349
+
350
+ if not auto_install:
351
+ return SetupReport(items=items)
352
+
353
+ _log("Missing required dependencies — installing…")
354
+ return install_missing(want_anthropic=want_anthropic, log=_log)
355
+
356
+
357
+ def catalog_wants_anthropic(repo: Path | None = None) -> bool:
358
+ """True if config/catalog selects an Anthropic profile."""
359
+ try:
360
+ from mobiflow.config import find_config, load_config
361
+
362
+ cfg_path = find_config(repo) if repo else find_config()
363
+ if not cfg_path:
364
+ return False
365
+ cfg = load_config(cfg_path.parent)
366
+ for name in (cfg.llm.discovery, cfg.llm.codegen):
367
+ if not name:
368
+ continue
369
+ try:
370
+ entry = cfg.load_catalog().get(name)
371
+ except Exception: # noqa: BLE001
372
+ continue
373
+ if entry.provider.lower() in ("anthropic", "claude"):
374
+ return True
375
+ except Exception: # noqa: BLE001
376
+ return False
377
+ return False