kctl-react 0.6.2__py3-none-any.whl

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 (102) hide show
  1. kctl_react/__init__.py +3 -0
  2. kctl_react/__main__.py +5 -0
  3. kctl_react/cli.py +201 -0
  4. kctl_react/commands/__init__.py +0 -0
  5. kctl_react/commands/a11y.py +78 -0
  6. kctl_react/commands/affected.py +170 -0
  7. kctl_react/commands/apps.py +353 -0
  8. kctl_react/commands/build.py +376 -0
  9. kctl_react/commands/bundle_cmd.py +217 -0
  10. kctl_react/commands/cap.py +1465 -0
  11. kctl_react/commands/clean.py +76 -0
  12. kctl_react/commands/codegen.py +491 -0
  13. kctl_react/commands/compliance.py +587 -0
  14. kctl_react/commands/config_cmd.py +368 -0
  15. kctl_react/commands/dashboard.py +163 -0
  16. kctl_react/commands/deploy.py +318 -0
  17. kctl_react/commands/deps.py +792 -0
  18. kctl_react/commands/dev.py +96 -0
  19. kctl_react/commands/docker_cmd.py +73 -0
  20. kctl_react/commands/doctor.py +170 -0
  21. kctl_react/commands/e2e.py +343 -0
  22. kctl_react/commands/env.py +155 -0
  23. kctl_react/commands/i18n.py +310 -0
  24. kctl_react/commands/lint.py +306 -0
  25. kctl_react/commands/maintenance.py +308 -0
  26. kctl_react/commands/monitor_cmd.py +50 -0
  27. kctl_react/commands/observe.py +34 -0
  28. kctl_react/commands/packages.py +129 -0
  29. kctl_react/commands/perf.py +762 -0
  30. kctl_react/commands/pipeline.py +289 -0
  31. kctl_react/commands/pwa.py +193 -0
  32. kctl_react/commands/scaffold.py +323 -0
  33. kctl_react/commands/security.py +660 -0
  34. kctl_react/commands/skill_cmd.py +54 -0
  35. kctl_react/commands/state.py +254 -0
  36. kctl_react/commands/test_cmd.py +418 -0
  37. kctl_react/commands/ui_audit.py +889 -0
  38. kctl_react/core/__init__.py +0 -0
  39. kctl_react/core/analyzers.py +200 -0
  40. kctl_react/core/callbacks.py +70 -0
  41. kctl_react/core/compliance/__init__.py +3 -0
  42. kctl_react/core/compliance/api_check/__init__.py +3 -0
  43. kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
  44. kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
  45. kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
  46. kctl_react/core/compliance/api_check/checks/naming.py +60 -0
  47. kctl_react/core/compliance/api_check/checks/params.py +44 -0
  48. kctl_react/core/compliance/api_check/checks/requests.py +57 -0
  49. kctl_react/core/compliance/api_check/checks/types.py +55 -0
  50. kctl_react/core/compliance/api_check/hooks.py +133 -0
  51. kctl_react/core/compliance/api_check/matcher.py +55 -0
  52. kctl_react/core/compliance/api_check/schema.py +151 -0
  53. kctl_react/core/compliance/api_health/__init__.py +35 -0
  54. kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
  55. kctl_react/core/compliance/api_health/checks/auth.py +72 -0
  56. kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
  57. kctl_react/core/compliance/api_health/checks/response.py +55 -0
  58. kctl_react/core/compliance/api_health/checks/timing.py +38 -0
  59. kctl_react/core/compliance/api_health/client.py +99 -0
  60. kctl_react/core/compliance/api_health/sampler.py +16 -0
  61. kctl_react/core/compliance/checks/__init__.py +47 -0
  62. kctl_react/core/compliance/checks/api.py +101 -0
  63. kctl_react/core/compliance/checks/codegen.py +94 -0
  64. kctl_react/core/compliance/checks/darkmode.py +57 -0
  65. kctl_react/core/compliance/checks/errors.py +68 -0
  66. kctl_react/core/compliance/checks/features.py +66 -0
  67. kctl_react/core/compliance/checks/i18n_check.py +105 -0
  68. kctl_react/core/compliance/checks/imports.py +86 -0
  69. kctl_react/core/compliance/checks/navigation.py +62 -0
  70. kctl_react/core/compliance/checks/practices.py +122 -0
  71. kctl_react/core/compliance/checks/providers.py +85 -0
  72. kctl_react/core/compliance/checks/pwa.py +101 -0
  73. kctl_react/core/compliance/checks/responsive.py +47 -0
  74. kctl_react/core/compliance/checks/scripts.py +85 -0
  75. kctl_react/core/compliance/checks/shadcn.py +51 -0
  76. kctl_react/core/compliance/checks/structure.py +76 -0
  77. kctl_react/core/compliance/checks/testing.py +83 -0
  78. kctl_react/core/compliance/checks/theme.py +92 -0
  79. kctl_react/core/compliance/checks/ui_standard.py +185 -0
  80. kctl_react/core/compliance/checks/vite.py +83 -0
  81. kctl_react/core/compliance/engine.py +87 -0
  82. kctl_react/core/compliance/exceptions_map.py +15 -0
  83. kctl_react/core/compliance/fixes/__init__.py +33 -0
  84. kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
  85. kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
  86. kctl_react/core/compliance/fixes/imports_fix.py +36 -0
  87. kctl_react/core/compliance/fixes/structure_fix.py +20 -0
  88. kctl_react/core/compliance/fixes/theme_fix.py +29 -0
  89. kctl_react/core/compliance/models.py +106 -0
  90. kctl_react/core/config.py +201 -0
  91. kctl_react/core/discovery.py +185 -0
  92. kctl_react/core/exceptions.py +17 -0
  93. kctl_react/core/git.py +146 -0
  94. kctl_react/core/history.py +121 -0
  95. kctl_react/core/output.py +5 -0
  96. kctl_react/core/plugins.py +13 -0
  97. kctl_react/core/runner.py +34 -0
  98. kctl_react/py.typed +0 -0
  99. kctl_react-0.6.2.dist-info/METADATA +17 -0
  100. kctl_react-0.6.2.dist-info/RECORD +102 -0
  101. kctl_react-0.6.2.dist-info/WHEEL +4 -0
  102. kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1465 @@
1
+ """Capacitor native app commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+
14
+ from kctl_react.core.callbacks import AppContext
15
+ from kctl_react.core.discovery import get_app_dir
16
+ from kctl_react.core.exceptions import CommandError
17
+ from kctl_react.core.runner import run, run_pnpm, run_turbo
18
+
19
+ app = typer.Typer(help="Capacitor native app management (Android/iOS).")
20
+
21
+ _DEFAULT_APP_ID_PREFIX = "io.kodeme"
22
+
23
+ # Minimum versions for a successful build
24
+ _MIN_JAVA_MAJOR = 21
25
+ _MIN_GRADLE_VERSION = "8.11"
26
+ _MIN_ANDROID_SDK = 23
27
+ _COMPILE_SDK = 35
28
+ _REQUIRED_CAP_PACKAGES = [
29
+ "@capacitor/core",
30
+ "@capacitor/cli",
31
+ "@capacitor/app",
32
+ ]
33
+ _ANDROID_CAP_PACKAGE = "@capacitor/android"
34
+ _IOS_CAP_PACKAGE = "@capacitor/ios"
35
+
36
+
37
+ # ── Helpers ────────────────────────────────────────────────────────
38
+
39
+
40
+ def _has_capacitor(app_dir: Path) -> bool:
41
+ """Check if an app has Capacitor configured."""
42
+ return (app_dir / "capacitor.config.ts").exists() or (app_dir / "capacitor.config.json").exists()
43
+
44
+
45
+ def _has_platform(app_dir: Path, platform: str) -> bool:
46
+ """Check if a native platform directory exists."""
47
+ return (app_dir / platform).is_dir()
48
+
49
+
50
+ def _run_cap(args: list[str], app_dir: Path, timeout: int = 120) -> None:
51
+ """Run a Capacitor CLI command in an app directory."""
52
+ run(["npx", "cap", *args], cwd=app_dir, capture=False, timeout=timeout)
53
+
54
+
55
+ def _get_java_major_version() -> int | None:
56
+ """Get the major Java version (e.g. 21 for openjdk 21.0.6)."""
57
+ try:
58
+ result = run(["java", "--version"], cwd=Path.cwd(), capture=True, timeout=5)
59
+ for line in (result.stdout + result.stderr).splitlines():
60
+ match = re.search(r"(\d+)\.(\d+)", line)
61
+ if match:
62
+ return int(match.group(1))
63
+ except Exception:
64
+ pass
65
+ return None
66
+
67
+
68
+ def _get_gradle_version(android_dir: Path) -> str | None:
69
+ """Read Gradle version from wrapper properties."""
70
+ props = android_dir / "gradle" / "wrapper" / "gradle-wrapper.properties"
71
+ if not props.exists():
72
+ return None
73
+ try:
74
+ text = props.read_text()
75
+ match = re.search(r"gradle-(\d+\.\d+(?:\.\d+)?)", text)
76
+ return match.group(1) if match else None
77
+ except Exception:
78
+ return None
79
+
80
+
81
+ def _get_android_min_sdk(android_dir: Path) -> int | None:
82
+ """Read minSdkVersion from variables.gradle."""
83
+ variables = android_dir / "variables.gradle"
84
+ if not variables.exists():
85
+ return None
86
+ try:
87
+ text = variables.read_text()
88
+ match = re.search(r"minSdkVersion\s*=\s*(\d+)", text)
89
+ return int(match.group(1)) if match else None
90
+ except Exception:
91
+ return None
92
+
93
+
94
+ def _get_pkg_deps(app_dir: Path) -> dict[str, str]:
95
+ """Get all deps + devDeps from package.json."""
96
+ pkg_file = app_dir / "package.json"
97
+ if not pkg_file.exists():
98
+ return {}
99
+ try:
100
+ pkg = json.loads(pkg_file.read_text())
101
+ return {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
102
+ except Exception:
103
+ return {}
104
+
105
+
106
+ def _check_cap_version_match(deps: dict[str, str]) -> tuple[bool, str]:
107
+ """Check that @capacitor/core and @capacitor/android|ios are on the same major version."""
108
+ core_ver = deps.get("@capacitor/core", "")
109
+ android_ver = deps.get("@capacitor/android", "")
110
+ ios_ver = deps.get("@capacitor/ios", "")
111
+
112
+ def _major(ver: str) -> int | None:
113
+ match = re.search(r"(\d+)", ver)
114
+ return int(match.group(1)) if match else None
115
+
116
+ core_major = _major(core_ver)
117
+ issues: list[str] = []
118
+
119
+ if core_major and android_ver:
120
+ android_major = _major(android_ver)
121
+ if android_major and core_major != android_major:
122
+ issues.append(f"@capacitor/core@{core_ver} vs @capacitor/android@{android_ver}")
123
+ if core_major and ios_ver:
124
+ ios_major = _major(ios_ver)
125
+ if ios_major and core_major != ios_major:
126
+ issues.append(f"@capacitor/core@{core_ver} vs @capacitor/ios@{ios_ver}")
127
+
128
+ if issues:
129
+ return False, "; ".join(issues)
130
+ return True, ""
131
+
132
+
133
+ # ── Validation engine ──────────────────────────────────────────────
134
+
135
+
136
+ def _validate_environment(
137
+ root: Path,
138
+ app_dir: Path | None,
139
+ platform: str | None,
140
+ *,
141
+ check_deps: bool = True,
142
+ check_build_tools: bool = True,
143
+ ) -> list[dict]:
144
+ """Run all validation checks and return results list.
145
+
146
+ Each result: {"check": str, "status": "pass"|"fail"|"warn", "detail": str, "fix": str}
147
+ """
148
+ checks: list[dict] = []
149
+
150
+ # 1. Node.js
151
+ try:
152
+ result = run(["node", "--version"], cwd=root, capture=True, timeout=5)
153
+ checks.append({"check": "node", "status": "pass", "detail": result.stdout.strip(), "fix": ""})
154
+ except Exception:
155
+ checks.append(
156
+ {
157
+ "check": "node",
158
+ "status": "fail",
159
+ "detail": "not found",
160
+ "fix": "Install Node.js 20+",
161
+ }
162
+ )
163
+
164
+ # 2. pnpm
165
+ try:
166
+ result = run(["pnpm", "--version"], cwd=root, capture=True, timeout=5)
167
+ checks.append({"check": "pnpm", "status": "pass", "detail": f"v{result.stdout.strip()}", "fix": ""})
168
+ except Exception:
169
+ checks.append(
170
+ {
171
+ "check": "pnpm",
172
+ "status": "fail",
173
+ "detail": "not found",
174
+ "fix": "Install pnpm: npm i -g pnpm",
175
+ }
176
+ )
177
+
178
+ # 3. Java (≥21 for Capacitor Android 8.x)
179
+ if not platform or platform == "android":
180
+ java_major = _get_java_major_version()
181
+ if java_major is None:
182
+ checks.append(
183
+ {
184
+ "check": "java",
185
+ "status": "fail",
186
+ "detail": "not found",
187
+ "fix": "Install Java 21+: sdk install java 21.0.6-tem (via SDKMAN)",
188
+ }
189
+ )
190
+ elif java_major < _MIN_JAVA_MAJOR:
191
+ checks.append(
192
+ {
193
+ "check": "java",
194
+ "status": "fail",
195
+ "detail": f"Java {java_major} (need ≥{_MIN_JAVA_MAJOR})",
196
+ "fix": f"Upgrade to Java {_MIN_JAVA_MAJOR}+: sdk install java 21.0.6-tem",
197
+ }
198
+ )
199
+ else:
200
+ checks.append({"check": "java", "status": "pass", "detail": f"Java {java_major}", "fix": ""})
201
+
202
+ # 4. ANDROID_HOME
203
+ if not platform or platform == "android":
204
+ android_home = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT", "")
205
+ if android_home and Path(android_home).is_dir():
206
+ checks.append({"check": "ANDROID_HOME", "status": "pass", "detail": android_home, "fix": ""})
207
+
208
+ # 4b. Check for platform-tools and build-tools
209
+ if check_build_tools:
210
+ pt = Path(android_home) / "platform-tools"
211
+ bt = Path(android_home) / "build-tools"
212
+ if not pt.is_dir():
213
+ checks.append(
214
+ {
215
+ "check": "platform-tools",
216
+ "status": "fail",
217
+ "detail": "not installed",
218
+ "fix": "sdkmanager 'platform-tools'",
219
+ }
220
+ )
221
+ if bt.is_dir():
222
+ versions = sorted(bt.iterdir())
223
+ if versions:
224
+ checks.append(
225
+ {
226
+ "check": "build-tools",
227
+ "status": "pass",
228
+ "detail": versions[-1].name,
229
+ "fix": "",
230
+ }
231
+ )
232
+ else:
233
+ checks.append(
234
+ {
235
+ "check": "build-tools",
236
+ "status": "fail",
237
+ "detail": "no versions installed",
238
+ "fix": "sdkmanager 'build-tools;35.0.0'",
239
+ }
240
+ )
241
+ else:
242
+ checks.append(
243
+ {
244
+ "check": "build-tools",
245
+ "status": "fail",
246
+ "detail": "not installed",
247
+ "fix": "sdkmanager 'build-tools;35.0.0'",
248
+ }
249
+ )
250
+ else:
251
+ checks.append(
252
+ {
253
+ "check": "ANDROID_HOME",
254
+ "status": "fail",
255
+ "detail": "not set",
256
+ "fix": "export ANDROID_HOME=~/Android/Sdk (add to ~/.zshrc)",
257
+ }
258
+ )
259
+
260
+ # 5. Xcode + CocoaPods (macOS only)
261
+ import platform as plat
262
+
263
+ if plat.system() == "Darwin" and (not platform or platform == "ios"):
264
+ try:
265
+ result = run(["xcodebuild", "-version"], cwd=root, capture=True, timeout=5)
266
+ version = result.stdout.strip().splitlines()[0] if result.stdout else ""
267
+ checks.append({"check": "xcode", "status": "pass", "detail": version, "fix": ""})
268
+ except Exception:
269
+ checks.append(
270
+ {
271
+ "check": "xcode",
272
+ "status": "fail",
273
+ "detail": "not found",
274
+ "fix": "Install Xcode from App Store",
275
+ }
276
+ )
277
+
278
+ try:
279
+ result = run(["pod", "--version"], cwd=root, capture=True, timeout=5)
280
+ checks.append({"check": "cocoapods", "status": "pass", "detail": result.stdout.strip(), "fix": ""})
281
+ except Exception:
282
+ checks.append(
283
+ {
284
+ "check": "cocoapods",
285
+ "status": "fail",
286
+ "detail": "not found",
287
+ "fix": "sudo gem install cocoapods",
288
+ }
289
+ )
290
+
291
+ # 6-10: App-specific checks
292
+ if app_dir and check_deps:
293
+ deps = _get_pkg_deps(app_dir)
294
+
295
+ # 6. Capacitor config file
296
+ if _has_capacitor(app_dir):
297
+ checks.append({"check": "capacitor.config", "status": "pass", "detail": "found", "fix": ""})
298
+ else:
299
+ checks.append(
300
+ {
301
+ "check": "capacitor.config",
302
+ "status": "fail",
303
+ "detail": "missing",
304
+ "fix": f"kctl-react cap init {app_dir.name}",
305
+ }
306
+ )
307
+
308
+ # 7. Required Capacitor packages
309
+ for pkg in _REQUIRED_CAP_PACKAGES:
310
+ if pkg in deps:
311
+ checks.append({"check": pkg, "status": "pass", "detail": deps[pkg], "fix": ""})
312
+ else:
313
+ checks.append(
314
+ {
315
+ "check": pkg,
316
+ "status": "fail",
317
+ "detail": "not installed",
318
+ "fix": f"pnpm add {pkg} --filter=@kodemeio/{app_dir.name}",
319
+ }
320
+ )
321
+
322
+ # 8. Platform-specific package
323
+ if platform == "android":
324
+ if _ANDROID_CAP_PACKAGE in deps:
325
+ checks.append(
326
+ {
327
+ "check": _ANDROID_CAP_PACKAGE,
328
+ "status": "pass",
329
+ "detail": deps[_ANDROID_CAP_PACKAGE],
330
+ "fix": "",
331
+ }
332
+ )
333
+ else:
334
+ checks.append(
335
+ {
336
+ "check": _ANDROID_CAP_PACKAGE,
337
+ "status": "fail",
338
+ "detail": "not installed",
339
+ "fix": f"pnpm add {_ANDROID_CAP_PACKAGE} --filter=@kodemeio/{app_dir.name}",
340
+ }
341
+ )
342
+ elif platform == "ios":
343
+ if _IOS_CAP_PACKAGE in deps:
344
+ checks.append(
345
+ {
346
+ "check": _IOS_CAP_PACKAGE,
347
+ "status": "pass",
348
+ "detail": deps[_IOS_CAP_PACKAGE],
349
+ "fix": "",
350
+ }
351
+ )
352
+ else:
353
+ checks.append(
354
+ {
355
+ "check": _IOS_CAP_PACKAGE,
356
+ "status": "fail",
357
+ "detail": "not installed",
358
+ "fix": f"pnpm add {_IOS_CAP_PACKAGE} --filter=@kodemeio/{app_dir.name}",
359
+ }
360
+ )
361
+
362
+ # 9. Version mismatch
363
+ match_ok, mismatch_detail = _check_cap_version_match(deps)
364
+ if not match_ok:
365
+ checks.append(
366
+ {
367
+ "check": "cap-version-match",
368
+ "status": "fail",
369
+ "detail": mismatch_detail,
370
+ "fix": "Align all @capacitor/* packages to the same major version",
371
+ }
372
+ )
373
+ elif any(p in deps for p in (_ANDROID_CAP_PACKAGE, _IOS_CAP_PACKAGE)):
374
+ checks.append({"check": "cap-version-match", "status": "pass", "detail": "versions aligned", "fix": ""})
375
+
376
+ # 10. workbox-window (required for vite-plugin-pwa build)
377
+ if "vite-plugin-pwa" in deps and "workbox-window" not in deps:
378
+ checks.append(
379
+ {
380
+ "check": "workbox-window",
381
+ "status": "fail",
382
+ "detail": "missing (required by vite-plugin-pwa)",
383
+ "fix": f"pnpm add -D workbox-window --filter=@kodemeio/{app_dir.name}",
384
+ }
385
+ )
386
+
387
+ # 11. dist/ exists (needed for cap sync/add)
388
+ dist = app_dir / "dist"
389
+ if dist.is_dir() and (dist / "index.html").exists():
390
+ checks.append({"check": "dist/", "status": "pass", "detail": "built", "fix": ""})
391
+ else:
392
+ checks.append(
393
+ {
394
+ "check": "dist/",
395
+ "status": "warn",
396
+ "detail": "not built (will build automatically)",
397
+ "fix": f"pnpm turbo run build --filter=@kodemeio/{app_dir.name}",
398
+ }
399
+ )
400
+
401
+ # 12-14: Android project checks (if android/ exists)
402
+ if app_dir and platform == "android" and _has_platform(app_dir, "android"):
403
+ android_dir = app_dir / "android"
404
+
405
+ # 12. Gradle version
406
+ gradle_ver = _get_gradle_version(android_dir)
407
+ if gradle_ver:
408
+ # Compare semver-ish
409
+ gradle_parts = [int(x) for x in gradle_ver.split(".")[:2]]
410
+ min_parts = [int(x) for x in _MIN_GRADLE_VERSION.split(".")[:2]]
411
+ if gradle_parts >= min_parts:
412
+ checks.append({"check": "gradle", "status": "pass", "detail": gradle_ver, "fix": ""})
413
+ else:
414
+ checks.append(
415
+ {
416
+ "check": "gradle",
417
+ "status": "fail",
418
+ "detail": f"{gradle_ver} (need ≥{_MIN_GRADLE_VERSION})",
419
+ "fix": (
420
+ "Update distributionUrl in android/gradle/wrapper/"
421
+ "gradle-wrapper.properties to gradle-8.11.1-all.zip"
422
+ ),
423
+ }
424
+ )
425
+ else:
426
+ checks.append(
427
+ {
428
+ "check": "gradle",
429
+ "status": "warn",
430
+ "detail": "cannot read version",
431
+ "fix": "",
432
+ }
433
+ )
434
+
435
+ # 13. minSdkVersion
436
+ min_sdk = _get_android_min_sdk(android_dir)
437
+ if min_sdk is not None:
438
+ if min_sdk >= _MIN_ANDROID_SDK:
439
+ checks.append({"check": "minSdkVersion", "status": "pass", "detail": str(min_sdk), "fix": ""})
440
+ else:
441
+ checks.append(
442
+ {
443
+ "check": "minSdkVersion",
444
+ "status": "fail",
445
+ "detail": f"{min_sdk} (need ≥{_MIN_ANDROID_SDK})",
446
+ "fix": f"Set minSdkVersion = {_MIN_ANDROID_SDK} in android/variables.gradle",
447
+ }
448
+ )
449
+
450
+ # 14. gradlew executable
451
+ gradlew = android_dir / "gradlew"
452
+ if gradlew.exists():
453
+ if os.access(gradlew, os.X_OK):
454
+ checks.append({"check": "gradlew", "status": "pass", "detail": "executable", "fix": ""})
455
+ else:
456
+ checks.append(
457
+ {
458
+ "check": "gradlew",
459
+ "status": "fail",
460
+ "detail": "not executable",
461
+ "fix": "chmod +x apps/<app>/android/gradlew",
462
+ }
463
+ )
464
+
465
+ return checks
466
+
467
+
468
+ def _show_checks(out: object, title: str, checks: list[dict]) -> int:
469
+ """Display validation checks and return failure count."""
470
+ rows: list[list[str]] = []
471
+ for c in checks:
472
+ status_map = {
473
+ "pass": "[green]PASS[/green]",
474
+ "fail": "[red]FAIL[/red]",
475
+ "warn": "[yellow]WARN[/yellow]",
476
+ }
477
+ fix_text = c["fix"] if c["fix"] else ""
478
+ rows.append([c["check"], status_map.get(c["status"], c["status"]), c["detail"], fix_text])
479
+
480
+ out.table(
481
+ title,
482
+ [("Check", "cyan"), ("Status", ""), ("Detail", "dim"), ("Fix", "")],
483
+ rows,
484
+ data_for_json=checks,
485
+ )
486
+
487
+ failures = sum(1 for c in checks if c["status"] == "fail")
488
+ warnings = sum(1 for c in checks if c["status"] == "warn")
489
+ passed = sum(1 for c in checks if c["status"] == "pass")
490
+
491
+ if failures:
492
+ out.error(f"{passed} passed, {failures} failed, {warnings} warnings — fix FAIL items before building")
493
+ elif warnings:
494
+ out.warn(f"{passed} passed, {warnings} warnings — ready to build (warnings are non-blocking)")
495
+ else:
496
+ out.success(f"All {passed} checks passed — ready to build")
497
+
498
+ return failures
499
+
500
+
501
+ def _preflight(
502
+ out: object,
503
+ root: Path,
504
+ app_dir: Path,
505
+ platform: str,
506
+ ) -> None:
507
+ """Run preflight validation and abort on failures."""
508
+ checks = _validate_environment(root, app_dir, platform)
509
+ failures = _show_checks(out, f"Preflight — {app_dir.name} ({platform})", checks)
510
+ if failures:
511
+ raise typer.Exit(1) from None
512
+
513
+
514
+ # ── Commands ───────────────────────────────────────────────────────
515
+
516
+
517
+ @app.command()
518
+ def status(ctx: typer.Context) -> None:
519
+ """Show Capacitor status for all apps."""
520
+ actx: AppContext = ctx.obj
521
+ out = actx.output
522
+ root = actx.project_root
523
+
524
+ rows: list[list[str]] = []
525
+ json_data: list[dict] = []
526
+
527
+ for name in actx.app_names:
528
+ app_dir = get_app_dir(root, name)
529
+ has_cap = _has_capacitor(app_dir)
530
+ has_android = _has_platform(app_dir, "android")
531
+ has_ios = _has_platform(app_dir, "ios")
532
+
533
+ cap_status = "[green]yes[/green]" if has_cap else "[dim]no[/dim]"
534
+ android_status = "[green]yes[/green]" if has_android else "[dim]no[/dim]"
535
+ ios_status = "[green]yes[/green]" if has_ios else "[dim]no[/dim]"
536
+
537
+ rows.append([name, cap_status, android_status, ios_status])
538
+ json_data.append(
539
+ {
540
+ "app": name,
541
+ "capacitor": has_cap,
542
+ "android": has_android,
543
+ "ios": has_ios,
544
+ }
545
+ )
546
+
547
+ out.table(
548
+ "Capacitor Status",
549
+ [("App", "cyan"), ("Capacitor", ""), ("Android", ""), ("iOS", "")],
550
+ rows,
551
+ data_for_json=json_data,
552
+ )
553
+
554
+ cap_count = sum(1 for d in json_data if d["capacitor"])
555
+ out.info(f"{cap_count}/{len(json_data)} app(s) have Capacitor configured")
556
+
557
+
558
+ @app.command()
559
+ def doctor(
560
+ ctx: typer.Context,
561
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for general check)")] = None,
562
+ platform: Annotated[str | None, typer.Option("--platform", "-p", help="Platform to check")] = None,
563
+ ) -> None:
564
+ """Comprehensive Capacitor build environment validation.
565
+
566
+ Checks: Node, pnpm, Java ≥21, ANDROID_HOME, build-tools, platform-tools,
567
+ Capacitor deps, version alignment, Gradle version, minSdkVersion, workbox-window,
568
+ dist/ output, gradlew permissions. On macOS also checks Xcode and CocoaPods.
569
+ """
570
+ actx: AppContext = ctx.obj
571
+ out = actx.output
572
+ root = actx.project_root
573
+
574
+ app_dir = None
575
+ if app_name:
576
+ actx.validate_app(app_name)
577
+ app_dir = get_app_dir(root, app_name)
578
+
579
+ checks = _validate_environment(root, app_dir, platform)
580
+ _show_checks(out, "Capacitor Doctor" + (f" — {app_name}" if app_name else ""), checks)
581
+
582
+
583
+ @app.command()
584
+ def init(
585
+ ctx: typer.Context,
586
+ app_name: Annotated[str, typer.Argument(help="App name")],
587
+ app_id: Annotated[str | None, typer.Option("--app-id", help="Bundle ID (default: io.kodeme.<app>)")] = None,
588
+ ) -> None:
589
+ """Initialize Capacitor for an app (creates capacitor.config.ts)."""
590
+ actx: AppContext = ctx.obj
591
+ out = actx.output
592
+ root = actx.project_root
593
+
594
+ actx.validate_app(app_name)
595
+ app_dir = get_app_dir(root, app_name)
596
+
597
+ if _has_capacitor(app_dir):
598
+ out.warn(f"{app_name}: Capacitor already configured")
599
+ return
600
+
601
+ bundle_id = app_id or f"{_DEFAULT_APP_ID_PREFIX}.{app_name}"
602
+ app_info = actx.apps[app_name]
603
+ display_name = app_info.get("name", app_name)
604
+ theme_color = "#14b8a6"
605
+
606
+ config_content = f'''import type {{ CapacitorConfig }} from "@capacitor/cli";
607
+
608
+ const config: CapacitorConfig = {{
609
+ appId: "{bundle_id}",
610
+ appName: "{display_name}",
611
+ webDir: "dist",
612
+ server: {{
613
+ androidScheme: "https",
614
+ }},
615
+ plugins: {{
616
+ SplashScreen: {{
617
+ launchAutoHide: true,
618
+ showSpinner: false,
619
+ backgroundColor: "{theme_color}",
620
+ }},
621
+ Keyboard: {{
622
+ resize: "body",
623
+ resizeOnFullScreen: true,
624
+ }},
625
+ }},
626
+ android: {{
627
+ allowMixedContent: false,
628
+ }},
629
+ ios: {{
630
+ contentInset: "automatic",
631
+ }},
632
+ }};
633
+
634
+ export default config;
635
+ '''
636
+
637
+ (app_dir / "capacitor.config.ts").write_text(config_content)
638
+ out.success(f"Created capacitor.config.ts for {app_name} (appId: {bundle_id})")
639
+
640
+ # Check required deps
641
+ deps = _get_pkg_deps(app_dir)
642
+ missing = [pkg for pkg in _REQUIRED_CAP_PACKAGES if pkg not in deps]
643
+ if missing:
644
+ out.info("Install required packages:")
645
+ out.info(f" pnpm add {' '.join(missing)} --filter=@kodemeio/{app_name}")
646
+
647
+
648
+ @app.command()
649
+ def add(
650
+ ctx: typer.Context,
651
+ app_name: Annotated[str, typer.Argument(help="App name")],
652
+ platform: Annotated[str, typer.Argument(help="Platform: android or ios")],
653
+ ) -> None:
654
+ """Add a native platform (android/ios) to an app. Validates everything first."""
655
+ actx: AppContext = ctx.obj
656
+ out = actx.output
657
+ root = actx.project_root
658
+
659
+ actx.validate_app(app_name)
660
+ app_dir = get_app_dir(root, app_name)
661
+
662
+ if platform not in ("android", "ios"):
663
+ out.error("Platform must be 'android' or 'ios'")
664
+ raise typer.Exit(1)
665
+
666
+ if not _has_capacitor(app_dir):
667
+ out.error(f"{app_name}: No capacitor.config.ts — run `kctl-react cap init {app_name}` first")
668
+ raise typer.Exit(1)
669
+
670
+ if _has_platform(app_dir, platform):
671
+ out.warn(f"{app_name}: {platform}/ already exists")
672
+ return
673
+
674
+ # Pre-validate
675
+ out.info(f"Validating environment for {platform}...")
676
+ checks = _validate_environment(root, app_dir, platform, check_build_tools=False)
677
+ failures = sum(1 for c in checks if c["status"] == "fail")
678
+ if failures:
679
+ _show_checks(out, f"Pre-add validation — {app_name}", checks)
680
+ raise typer.Exit(1)
681
+
682
+ # Ensure platform package is installed
683
+ pkg_name = _ANDROID_CAP_PACKAGE if platform == "android" else _IOS_CAP_PACKAGE
684
+ deps = _get_pkg_deps(app_dir)
685
+ if pkg_name not in deps:
686
+ out.info(f"Installing {pkg_name}...")
687
+ try:
688
+ run_pnpm(["add", pkg_name, f"--filter=@kodemeio/{app_name}"], cwd=root, capture=False, timeout=60)
689
+ except CommandError as e:
690
+ out.error(f"Failed to install {pkg_name}: {e}")
691
+ raise typer.Exit(1) from None
692
+
693
+ # Ensure build exists
694
+ dist = app_dir / "dist"
695
+ if not dist.is_dir():
696
+ out.info("Building app first (cap add requires dist/)...")
697
+ run_turbo("build", root, filter_app=app_name, capture=False, timeout=300)
698
+
699
+ out.info(f"Adding {platform} platform to {app_name}...")
700
+ try:
701
+ _run_cap(["add", platform], app_dir, timeout=120)
702
+ except CommandError as e:
703
+ out.error(f"Failed to add {platform}: {e}")
704
+ raise typer.Exit(1) from None
705
+
706
+ # Post-add fixes for Android
707
+ if platform == "android":
708
+ _fix_android_project(out, app_dir / "android")
709
+
710
+ out.success(f"Added {platform} to {app_name}")
711
+
712
+
713
+ def _fix_android_project(out: object, android_dir: Path) -> None:
714
+ """Apply required fixes to a freshly-scaffolded Android project."""
715
+ # Fix 1: Upgrade Gradle wrapper to 8.11.1
716
+ props = android_dir / "gradle" / "wrapper" / "gradle-wrapper.properties"
717
+ if props.exists():
718
+ text = props.read_text()
719
+ old_ver = re.search(r"gradle-(\d+\.\d+(?:\.\d+)?)", text)
720
+ if old_ver:
721
+ old = old_ver.group(1)
722
+ old_parts = [int(x) for x in old.split(".")[:2]]
723
+ min_parts = [int(x) for x in _MIN_GRADLE_VERSION.split(".")[:2]]
724
+ if old_parts < min_parts:
725
+ new_text = re.sub(r"gradle-[\d.]+-(all|bin)", "gradle-8.11.1-all", text)
726
+ props.write_text(new_text)
727
+ out.info(f" Upgraded Gradle: {old} → 8.11.1")
728
+
729
+ # Fix 2: Bump minSdkVersion and compileSdkVersion
730
+ variables = android_dir / "variables.gradle"
731
+ if variables.exists():
732
+ text = variables.read_text()
733
+ changed = False
734
+
735
+ min_sdk = _get_android_min_sdk(android_dir)
736
+ if min_sdk is not None and min_sdk < _MIN_ANDROID_SDK:
737
+ text = re.sub(r"minSdkVersion\s*=\s*\d+", f"minSdkVersion = {_MIN_ANDROID_SDK}", text)
738
+ changed = True
739
+ out.info(f" Bumped minSdkVersion: {min_sdk} → {_MIN_ANDROID_SDK}")
740
+
741
+ compile_match = re.search(r"compileSdkVersion\s*=\s*(\d+)", text)
742
+ if compile_match and int(compile_match.group(1)) < _COMPILE_SDK:
743
+ text = re.sub(r"compileSdkVersion\s*=\s*\d+", f"compileSdkVersion = {_COMPILE_SDK}", text)
744
+ text = re.sub(r"targetSdkVersion\s*=\s*\d+", f"targetSdkVersion = {_COMPILE_SDK}", text)
745
+ changed = True
746
+ out.info(f" Bumped compileSdkVersion/targetSdkVersion → {_COMPILE_SDK}")
747
+
748
+ if changed:
749
+ variables.write_text(text)
750
+
751
+ # Fix 3: Make gradlew executable
752
+ gradlew = android_dir / "gradlew"
753
+ if gradlew.exists() and not os.access(gradlew, os.X_OK):
754
+ gradlew.chmod(0o755)
755
+ out.info(" Made gradlew executable")
756
+
757
+
758
+ @app.command()
759
+ def sync(
760
+ ctx: typer.Context,
761
+ app_name: Annotated[str, typer.Argument(help="App name")],
762
+ platform: Annotated[str | None, typer.Argument(help="Platform (omit for all)")] = None,
763
+ skip_build: Annotated[bool, typer.Option("--skip-build", help="Skip Vite build, only sync")] = False,
764
+ ) -> None:
765
+ """Build web assets and sync to native platforms."""
766
+ actx: AppContext = ctx.obj
767
+ out = actx.output
768
+ root = actx.project_root
769
+
770
+ actx.validate_app(app_name)
771
+ app_dir = get_app_dir(root, app_name)
772
+
773
+ if not _has_capacitor(app_dir):
774
+ out.error(f"{app_name}: No Capacitor config — run `kctl-react cap init {app_name}` first")
775
+ raise typer.Exit(1)
776
+
777
+ if not skip_build:
778
+ out.info(f"Building {app_name}...")
779
+ try:
780
+ run_turbo("build", root, filter_app=app_name, capture=False, timeout=300)
781
+ except CommandError as e:
782
+ out.error(f"Build failed: {e}")
783
+ raise typer.Exit(1) from None
784
+
785
+ sync_args = ["sync"]
786
+ if platform:
787
+ sync_args.append(platform)
788
+
789
+ out.info("Syncing web assets to native platform(s)...")
790
+ try:
791
+ _run_cap(sync_args, app_dir, timeout=120)
792
+ out.success(f"Synced {app_name}" + (f" ({platform})" if platform else ""))
793
+ except CommandError as e:
794
+ out.error(f"Sync failed: {e}")
795
+ raise typer.Exit(1) from None
796
+
797
+
798
+ @app.command("run")
799
+ def run_native(
800
+ ctx: typer.Context,
801
+ app_name: Annotated[str, typer.Argument(help="App name")],
802
+ platform: Annotated[str, typer.Argument(help="Platform: android or ios")],
803
+ target: Annotated[str | None, typer.Option("--target", "-t", help="Device/emulator target ID")] = None,
804
+ ) -> None:
805
+ """Build, sync, and deploy to a device or emulator."""
806
+ actx: AppContext = ctx.obj
807
+ out = actx.output
808
+ root = actx.project_root
809
+
810
+ actx.validate_app(app_name)
811
+ app_dir = get_app_dir(root, app_name)
812
+
813
+ if platform not in ("android", "ios"):
814
+ out.error("Platform must be 'android' or 'ios'")
815
+ raise typer.Exit(1)
816
+
817
+ if not _has_platform(app_dir, platform):
818
+ out.error(f"{app_name}: No {platform}/ directory — run `kctl-react cap add {app_name} {platform}` first")
819
+ raise typer.Exit(1)
820
+
821
+ _preflight(out, root, app_dir, platform)
822
+
823
+ out.info(f"Building and syncing {app_name}...")
824
+ run_turbo("build", root, filter_app=app_name, capture=False, timeout=300)
825
+
826
+ run_args = ["run", platform]
827
+ if target:
828
+ run_args.extend(["--target", target])
829
+
830
+ out.info(f"Deploying {app_name} to {platform}...")
831
+ try:
832
+ _run_cap(run_args, app_dir, timeout=300)
833
+ out.success(f"Deployed {app_name} to {platform}")
834
+ except CommandError as e:
835
+ out.error(f"Run failed: {e}")
836
+ raise typer.Exit(1) from None
837
+
838
+
839
+ @app.command("open")
840
+ def open_ide(
841
+ ctx: typer.Context,
842
+ app_name: Annotated[str, typer.Argument(help="App name")],
843
+ platform: Annotated[str, typer.Argument(help="Platform: android or ios")],
844
+ ) -> None:
845
+ """Open native project in Android Studio or Xcode."""
846
+ actx: AppContext = ctx.obj
847
+ out = actx.output
848
+ root = actx.project_root
849
+
850
+ actx.validate_app(app_name)
851
+ app_dir = get_app_dir(root, app_name)
852
+
853
+ if platform not in ("android", "ios"):
854
+ out.error("Platform must be 'android' or 'ios'")
855
+ raise typer.Exit(1)
856
+
857
+ if not _has_platform(app_dir, platform):
858
+ out.error(f"{app_name}: No {platform}/ directory — run `kctl-react cap add {app_name} {platform}` first")
859
+ raise typer.Exit(1)
860
+
861
+ ide = "Android Studio" if platform == "android" else "Xcode"
862
+ out.info(f"Opening {app_name} in {ide}...")
863
+ try:
864
+ _run_cap(["open", platform], app_dir, timeout=30)
865
+ except CommandError as e:
866
+ out.error(f"Failed to open {ide}: {e}")
867
+ raise typer.Exit(1) from None
868
+
869
+
870
+ @app.command()
871
+ def build(
872
+ ctx: typer.Context,
873
+ app_name: Annotated[str, typer.Argument(help="App name")],
874
+ platform: Annotated[str, typer.Argument(help="Platform: android or ios")],
875
+ release: Annotated[bool, typer.Option("--release", help="Build a release (signed) artifact")] = False,
876
+ ) -> None:
877
+ """Build native artifact (APK/AAB for Android, IPA for iOS). Validates everything first."""
878
+ actx: AppContext = ctx.obj
879
+ out = actx.output
880
+ root = actx.project_root
881
+
882
+ actx.validate_app(app_name)
883
+ app_dir = get_app_dir(root, app_name)
884
+
885
+ if platform not in ("android", "ios"):
886
+ out.error("Platform must be 'android' or 'ios'")
887
+ raise typer.Exit(1)
888
+
889
+ if not _has_platform(app_dir, platform):
890
+ out.error(f"{app_name}: No {platform}/ directory")
891
+ raise typer.Exit(1)
892
+
893
+ # Full preflight validation
894
+ _preflight(out, root, app_dir, platform)
895
+
896
+ # Build web + sync
897
+ out.info(f"Building web assets for {app_name}...")
898
+ run_turbo("build", root, filter_app=app_name, capture=False, timeout=300)
899
+
900
+ out.info("Syncing to native project...")
901
+ _run_cap(["sync", platform], app_dir, timeout=120)
902
+
903
+ build_type = "release" if release else "debug"
904
+ out.info(f"Building {build_type} {platform} artifact...")
905
+
906
+ if platform == "android":
907
+ gradle_task = "assembleRelease" if release else "assembleDebug"
908
+ gradle_dir = app_dir / "android"
909
+ try:
910
+ run(
911
+ ["./gradlew", gradle_task],
912
+ cwd=gradle_dir,
913
+ capture=False,
914
+ timeout=600,
915
+ )
916
+ apk_dir = gradle_dir / "app" / "build" / "outputs" / "apk" / build_type
917
+ apks = list(apk_dir.glob("*.apk")) if apk_dir.is_dir() else []
918
+ if apks:
919
+ from kctl_react.commands.build import _format_size
920
+
921
+ size = apks[0].stat().st_size
922
+ out.success(f"APK: {apks[0].name} ({_format_size(size)})")
923
+ else:
924
+ out.success("Android build complete")
925
+ except CommandError as e:
926
+ out.error(f"Gradle build failed: {e}")
927
+ raise typer.Exit(1) from None
928
+
929
+ elif platform == "ios":
930
+ out.info("Use Xcode to build iOS (or run `xcodebuild` manually)")
931
+ out.info(f" kctl-react cap open {app_name} ios")
932
+
933
+
934
+ @app.command()
935
+ def dev(
936
+ ctx: typer.Context,
937
+ app_name: Annotated[str, typer.Argument(help="App name")],
938
+ platform: Annotated[str, typer.Argument(help="Platform: android or ios")],
939
+ ) -> None:
940
+ """Start Vite dev server with live-reload on a native device."""
941
+ actx: AppContext = ctx.obj
942
+ out = actx.output
943
+ root = actx.project_root
944
+
945
+ actx.validate_app(app_name)
946
+ app_dir = get_app_dir(root, app_name)
947
+ app_info = actx.apps[app_name]
948
+ port = app_info["port"]
949
+
950
+ if not _has_platform(app_dir, platform):
951
+ out.error(f"{app_name}: No {platform}/ directory — run `kctl-react cap add {app_name} {platform}` first")
952
+ raise typer.Exit(1)
953
+
954
+ import socket
955
+
956
+ try:
957
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
958
+ s.connect(("8.8.8.8", 80))
959
+ local_ip = s.getsockname()[0]
960
+ s.close()
961
+ except Exception:
962
+ local_ip = "localhost"
963
+
964
+ out.info(f"Starting live-reload dev for {app_name} on {platform}")
965
+ out.info(f" Dev server: http://{local_ip}:{port}")
966
+ out.info(" The native app will connect to the Vite dev server for hot-reload.")
967
+ out.info(" Make sure your device is on the same network.")
968
+
969
+ try:
970
+ run(
971
+ ["npx", "cap", "run", platform, "--livereload", f"--host={local_ip}", f"--port={port}"],
972
+ cwd=app_dir,
973
+ capture=False,
974
+ timeout=0,
975
+ )
976
+ except CommandError:
977
+ pass
978
+ except KeyboardInterrupt:
979
+ out.info("Stopped")
980
+
981
+
982
+ # ── ADB device management ─────────────────────────────────────────
983
+
984
+
985
+ def _get_adb_path() -> str:
986
+ """Get adb binary path from ANDROID_HOME or PATH."""
987
+ android_home = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT", "")
988
+ if android_home:
989
+ adb = Path(android_home) / "platform-tools" / "adb"
990
+ if adb.exists():
991
+ return str(adb)
992
+ return "adb"
993
+
994
+
995
+ def _list_devices(root: Path) -> list[dict]:
996
+ """List connected ADB devices."""
997
+ adb = _get_adb_path()
998
+ try:
999
+ result = run([adb, "devices", "-l"], cwd=root, capture=True, timeout=10)
1000
+ except Exception:
1001
+ return []
1002
+
1003
+ devices: list[dict] = []
1004
+ for line in result.stdout.strip().splitlines()[1:]:
1005
+ if not line.strip() or "offline" in line:
1006
+ continue
1007
+ parts = line.split()
1008
+ if len(parts) < 2:
1009
+ continue
1010
+ serial = parts[0]
1011
+ state = parts[1]
1012
+
1013
+ # Extract model and product from key:value pairs
1014
+ info: dict[str, str] = {}
1015
+ for part in parts[2:]:
1016
+ if ":" in part:
1017
+ k, v = part.split(":", 1)
1018
+ info[k] = v
1019
+
1020
+ devices.append(
1021
+ {
1022
+ "serial": serial,
1023
+ "state": state,
1024
+ "model": info.get("model", ""),
1025
+ "product": info.get("product", ""),
1026
+ "transport_id": info.get("transport_id", ""),
1027
+ }
1028
+ )
1029
+ return devices
1030
+
1031
+
1032
+ def _get_app_id(app_dir: Path) -> str:
1033
+ """Read appId from capacitor.config.ts or .json."""
1034
+ for config_name in ("capacitor.config.ts", "capacitor.config.json"):
1035
+ config_file = app_dir / config_name
1036
+ if not config_file.exists():
1037
+ continue
1038
+ try:
1039
+ text = config_file.read_text()
1040
+ match = re.search(r'appId["\s:]+["\']([^"\']+)["\']', text)
1041
+ if match:
1042
+ return match.group(1)
1043
+ except Exception:
1044
+ pass
1045
+ return ""
1046
+
1047
+
1048
+ def _find_apk(app_dir: Path, build_type: str = "debug") -> Path | None:
1049
+ """Find the APK file for a given build type."""
1050
+ apk_dir = app_dir / "android" / "app" / "build" / "outputs" / "apk" / build_type
1051
+ if not apk_dir.is_dir():
1052
+ return None
1053
+ apks = sorted(apk_dir.glob("*.apk"), key=lambda f: f.stat().st_mtime, reverse=True)
1054
+ return apks[0] if apks else None
1055
+
1056
+
1057
+ @app.command()
1058
+ def devices(ctx: typer.Context) -> None:
1059
+ """List connected Android devices and emulators via ADB."""
1060
+ actx: AppContext = ctx.obj
1061
+ out = actx.output
1062
+ root = actx.project_root
1063
+
1064
+ devs = _list_devices(root)
1065
+
1066
+ if not devs:
1067
+ out.warn("No devices connected")
1068
+ out.info(" Connect a phone via USB (enable USB Debugging)")
1069
+ out.info(" Or start an emulator: kctl-react cap emulator --start")
1070
+ return
1071
+
1072
+ rows: list[list[str]] = []
1073
+ for d in devs:
1074
+ model = d["model"] or d["product"] or "unknown"
1075
+ is_emulator = d["serial"].startswith("emulator-")
1076
+ dtype = "emulator" if is_emulator else "device"
1077
+ rows.append([d["serial"], model, d["state"], dtype])
1078
+
1079
+ out.table(
1080
+ "Connected Devices",
1081
+ [("Serial", "cyan"), ("Model", ""), ("State", "green"), ("Type", "dim")],
1082
+ rows,
1083
+ data_for_json=devs,
1084
+ )
1085
+
1086
+
1087
+ @app.command()
1088
+ def install(
1089
+ ctx: typer.Context,
1090
+ app_name: Annotated[str, typer.Argument(help="App name")],
1091
+ device: Annotated[str | None, typer.Option("--device", "-d", help="Device serial (omit for first)")] = None,
1092
+ build_type: Annotated[str, typer.Option("--type", help="Build type: debug or release")] = "debug",
1093
+ ) -> None:
1094
+ """Install APK on a connected device or emulator."""
1095
+ actx: AppContext = ctx.obj
1096
+ out = actx.output
1097
+ root = actx.project_root
1098
+
1099
+ actx.validate_app(app_name)
1100
+ app_dir = get_app_dir(root, app_name)
1101
+
1102
+ # Find APK
1103
+ apk = _find_apk(app_dir, build_type)
1104
+ if not apk:
1105
+ out.error(f"No {build_type} APK found — run `kctl-react cap build {app_name} android` first")
1106
+ raise typer.Exit(1) from None
1107
+
1108
+ # Check device
1109
+ devs = _list_devices(root)
1110
+ if not devs:
1111
+ out.error("No devices connected — connect a phone or start an emulator")
1112
+ raise typer.Exit(1)
1113
+
1114
+ target = device or devs[0]["serial"]
1115
+ target_model = next((d["model"] or d["serial"] for d in devs if d["serial"] == target), target)
1116
+
1117
+ from kctl_react.commands.build import _format_size
1118
+
1119
+ out.info(f"Installing {apk.name} ({_format_size(apk.stat().st_size)}) on {target_model}...")
1120
+
1121
+ adb = _get_adb_path()
1122
+ try:
1123
+ run([adb, "-s", target, "install", "-r", str(apk)], cwd=root, capture=False, timeout=120)
1124
+ out.success(f"Installed on {target_model}")
1125
+ except CommandError as e:
1126
+ out.error(f"Install failed: {e}")
1127
+ raise typer.Exit(1) from None
1128
+
1129
+
1130
+ @app.command()
1131
+ def launch(
1132
+ ctx: typer.Context,
1133
+ app_name: Annotated[str, typer.Argument(help="App name")],
1134
+ device: Annotated[str | None, typer.Option("--device", "-d", help="Device serial")] = None,
1135
+ ) -> None:
1136
+ """Launch the app on a connected device or emulator."""
1137
+ actx: AppContext = ctx.obj
1138
+ out = actx.output
1139
+ root = actx.project_root
1140
+
1141
+ actx.validate_app(app_name)
1142
+ app_dir = get_app_dir(root, app_name)
1143
+
1144
+ app_id = _get_app_id(app_dir)
1145
+ if not app_id:
1146
+ out.error("Cannot read appId from capacitor config")
1147
+ raise typer.Exit(1)
1148
+
1149
+ devs = _list_devices(root)
1150
+ if not devs:
1151
+ out.error("No devices connected")
1152
+ raise typer.Exit(1)
1153
+
1154
+ target = device or devs[0]["serial"]
1155
+
1156
+ adb = _get_adb_path()
1157
+ activity = f"{app_id}/.MainActivity"
1158
+
1159
+ out.info(f"Launching {app_id} on {target}...")
1160
+ try:
1161
+ run(
1162
+ [adb, "-s", target, "shell", "am", "start", "-n", activity],
1163
+ cwd=root,
1164
+ capture=True,
1165
+ timeout=10,
1166
+ )
1167
+ out.success(f"Launched {app_name}")
1168
+ except CommandError as e:
1169
+ # Try with generic activity launcher
1170
+ try:
1171
+ run(
1172
+ [adb, "-s", target, "shell", "monkey", "-p", app_id, "-c", "android.intent.category.LAUNCHER", "1"],
1173
+ cwd=root,
1174
+ capture=True,
1175
+ timeout=10,
1176
+ )
1177
+ out.success(f"Launched {app_name}")
1178
+ except CommandError:
1179
+ out.error(f"Launch failed: {e}")
1180
+ raise typer.Exit(1) from None
1181
+
1182
+
1183
+ @app.command()
1184
+ def logs(
1185
+ ctx: typer.Context,
1186
+ app_name: Annotated[str, typer.Argument(help="App name")],
1187
+ device: Annotated[str | None, typer.Option("--device", "-d", help="Device serial")] = None,
1188
+ clear: Annotated[bool, typer.Option("--clear", "-c", help="Clear logcat before tailing")] = False,
1189
+ ) -> None:
1190
+ """Show live logcat output filtered to the app (Ctrl+C to stop)."""
1191
+ actx: AppContext = ctx.obj
1192
+ out = actx.output
1193
+ root = actx.project_root
1194
+
1195
+ actx.validate_app(app_name)
1196
+ app_dir = get_app_dir(root, app_name)
1197
+
1198
+ app_id = _get_app_id(app_dir)
1199
+ if not app_id:
1200
+ out.error("Cannot read appId from capacitor config")
1201
+ raise typer.Exit(1)
1202
+
1203
+ devs = _list_devices(root)
1204
+ if not devs:
1205
+ out.error("No devices connected")
1206
+ raise typer.Exit(1)
1207
+
1208
+ target = device or devs[0]["serial"]
1209
+ adb = _get_adb_path()
1210
+
1211
+ if clear:
1212
+ with contextlib.suppress(Exception):
1213
+ run([adb, "-s", target, "logcat", "-c"], cwd=root, capture=True, timeout=5)
1214
+
1215
+ out.info(f"Tailing logcat for {app_id} on {target} (Ctrl+C to stop)...")
1216
+
1217
+ # Get PID of the app for filtering
1218
+ try:
1219
+ pid_result = run(
1220
+ [adb, "-s", target, "shell", "pidof", app_id],
1221
+ cwd=root,
1222
+ capture=True,
1223
+ timeout=5,
1224
+ )
1225
+ pid = pid_result.stdout.strip()
1226
+ if pid:
1227
+ cmd = [adb, "-s", target, "logcat", f"--pid={pid}"]
1228
+ else:
1229
+ # App not running — filter by Capacitor/Chromium tags
1230
+ cmd = [adb, "-s", target, "logcat", "-s", "Capacitor:V", "Capacitor/Console:V", "chromium:V"]
1231
+ except Exception:
1232
+ cmd = [adb, "-s", target, "logcat", "-s", "Capacitor:V", "chromium:V"]
1233
+
1234
+ try:
1235
+ run(cmd, cwd=root, capture=False, timeout=0)
1236
+ except (CommandError, KeyboardInterrupt):
1237
+ out.info("Stopped")
1238
+
1239
+
1240
+ # ── Release signing ────────────────────────────────────────────────
1241
+
1242
+
1243
+ _KEYSTORE_DIR = ".kctl-react"
1244
+
1245
+
1246
+ def _keystore_path(root: Path, app_name: str) -> Path:
1247
+ return root / _KEYSTORE_DIR / f"{app_name}.keystore"
1248
+
1249
+
1250
+ @app.command()
1251
+ def keystore(
1252
+ ctx: typer.Context,
1253
+ app_name: Annotated[str, typer.Argument(help="App name")],
1254
+ alias: Annotated[str, typer.Option("--alias", help="Key alias")] = "release",
1255
+ validity: Annotated[int, typer.Option("--validity", help="Validity in days")] = 10000,
1256
+ ) -> None:
1257
+ """Generate a signing keystore for release builds.
1258
+
1259
+ Stores in .kctl-react/<app>.keystore (gitignored). Keep this file safe!
1260
+ """
1261
+ actx: AppContext = ctx.obj
1262
+ out = actx.output
1263
+ root = actx.project_root
1264
+
1265
+ actx.validate_app(app_name)
1266
+
1267
+ ks_path = _keystore_path(root, app_name)
1268
+ if ks_path.exists():
1269
+ out.warn(f"Keystore already exists: {ks_path}")
1270
+ out.info("Delete it first if you want to regenerate")
1271
+ return
1272
+
1273
+ actx.apps[app_name]
1274
+
1275
+ out.info(f"Generating signing keystore for {app_name}...")
1276
+ out.info(" You will be prompted for a keystore password and key details.")
1277
+
1278
+ ks_path.parent.mkdir(parents=True, exist_ok=True)
1279
+
1280
+ try:
1281
+ run(
1282
+ [
1283
+ "keytool",
1284
+ "-genkeypair",
1285
+ "-v",
1286
+ "-keystore",
1287
+ str(ks_path),
1288
+ "-alias",
1289
+ alias,
1290
+ "-keyalg",
1291
+ "RSA",
1292
+ "-keysize",
1293
+ "2048",
1294
+ "-validity",
1295
+ str(validity),
1296
+ "-dname",
1297
+ f"CN=Kodemeio {app_name.upper()}, OU=Mobile, O=Kodemeio, L=Jakarta, ST=DKI, C=ID",
1298
+ "-storepass",
1299
+ "kodemeio",
1300
+ "-keypass",
1301
+ "kodemeio",
1302
+ ],
1303
+ cwd=root,
1304
+ capture=True,
1305
+ timeout=30,
1306
+ )
1307
+ out.success(f"Keystore created: {ks_path}")
1308
+ out.warn("Default password is 'kodemeio' — change it for production!")
1309
+ out.info("This file is gitignored. Back it up securely (e.g. 1Password).")
1310
+ except CommandError as e:
1311
+ out.error(f"keytool failed: {e}")
1312
+ raise typer.Exit(1) from None
1313
+
1314
+ # Write signing config snippet
1315
+ _write_signing_config(out, root, app_name, ks_path, alias)
1316
+
1317
+
1318
+ def _write_signing_config(out: object, root: Path, app_name: str, ks_path: Path, alias: str) -> None:
1319
+ """Write or update signing config in android/app/build.gradle."""
1320
+ app_dir = get_app_dir(root, app_name)
1321
+ gradle_file = app_dir / "android" / "app" / "build.gradle"
1322
+ if not gradle_file.exists():
1323
+ out.info(" android/app/build.gradle not found — signing config will be applied when platform is added")
1324
+ return
1325
+
1326
+ text = gradle_file.read_text()
1327
+ if "signingConfigs" in text:
1328
+ out.info(" Signing config already exists in build.gradle")
1329
+ return
1330
+
1331
+ # Insert signing config before the first buildTypes block
1332
+ signing_block = f"""
1333
+ signingConfigs {{
1334
+ release {{
1335
+ storeFile file('{ks_path}')
1336
+ storePassword 'kodemeio'
1337
+ keyAlias '{alias}'
1338
+ keyPassword 'kodemeio'
1339
+ }}
1340
+ }}
1341
+ """
1342
+ # Add signingConfig to release buildType
1343
+ text = text.replace(
1344
+ "buildTypes {",
1345
+ signing_block + "\n buildTypes {",
1346
+ )
1347
+ text = text.replace(
1348
+ "release {",
1349
+ "release {\n signingConfig signingConfigs.release",
1350
+ 1,
1351
+ )
1352
+ gradle_file.write_text(text)
1353
+ out.success(" Added signing config to android/app/build.gradle")
1354
+
1355
+
1356
+ @app.command()
1357
+ def emulator(
1358
+ ctx: typer.Context,
1359
+ start: Annotated[bool, typer.Option("--start", help="Start the first available AVD")] = False,
1360
+ create: Annotated[str | None, typer.Option("--create", help="Create a new AVD with this name")] = None,
1361
+ ) -> None:
1362
+ """List, create, or start Android emulators."""
1363
+ actx: AppContext = ctx.obj
1364
+ out = actx.output
1365
+ root = actx.project_root
1366
+
1367
+ android_home = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT", "")
1368
+ if not android_home:
1369
+ out.error("ANDROID_HOME not set")
1370
+ raise typer.Exit(1)
1371
+
1372
+ emulator_bin = Path(android_home) / "emulator" / "emulator"
1373
+ avdmanager = Path(android_home) / "cmdline-tools" / "latest" / "bin" / "avdmanager"
1374
+
1375
+ # Create new AVD
1376
+ if create:
1377
+ out.info(f"Creating AVD: {create}...")
1378
+ sdkmanager = Path(android_home) / "cmdline-tools" / "latest" / "bin" / "sdkmanager"
1379
+
1380
+ # Install system image if needed
1381
+ out.info(" Ensuring system image is installed...")
1382
+ try:
1383
+ run(
1384
+ [str(sdkmanager), "system-images;android-35;google_apis;x86_64"],
1385
+ cwd=root,
1386
+ capture=False,
1387
+ timeout=300,
1388
+ )
1389
+ except Exception:
1390
+ out.warn(" Could not install system image — it may already exist")
1391
+
1392
+ try:
1393
+ run(
1394
+ [
1395
+ str(avdmanager),
1396
+ "create",
1397
+ "avd",
1398
+ "-n",
1399
+ create,
1400
+ "-k",
1401
+ "system-images;android-35;google_apis;x86_64",
1402
+ "-d",
1403
+ "pixel_6",
1404
+ "--force",
1405
+ ],
1406
+ cwd=root,
1407
+ capture=False,
1408
+ timeout=30,
1409
+ )
1410
+ out.success(f"Created AVD: {create}")
1411
+ except CommandError as e:
1412
+ out.error(f"Failed to create AVD: {e}")
1413
+ raise typer.Exit(1) from None
1414
+ return
1415
+
1416
+ # List AVDs
1417
+ avds: list[str] = []
1418
+ try:
1419
+ result = run([str(avdmanager), "list", "avd", "-c"], cwd=root, capture=True, timeout=10)
1420
+ avds = [line.strip() for line in result.stdout.strip().splitlines() if line.strip()]
1421
+ except Exception:
1422
+ # Try emulator -list-avds as fallback
1423
+ if emulator_bin.exists():
1424
+ try:
1425
+ result = run([str(emulator_bin), "-list-avds"], cwd=root, capture=True, timeout=10)
1426
+ avds = [line.strip() for line in result.stdout.strip().splitlines() if line.strip()]
1427
+ except Exception:
1428
+ pass
1429
+
1430
+ if not avds:
1431
+ out.warn("No AVDs found")
1432
+ out.info(" Create one: kctl-react cap emulator --create kodemeio-test")
1433
+ return
1434
+
1435
+ if start:
1436
+ avd_name = avds[0]
1437
+ out.info(f"Starting emulator: {avd_name}...")
1438
+ if not emulator_bin.exists():
1439
+ out.error("emulator binary not found — install via: sdkmanager 'emulator'")
1440
+ raise typer.Exit(1) from None
1441
+ try:
1442
+ # Run in background — emulator is long-running
1443
+ import subprocess
1444
+
1445
+ subprocess.Popen(
1446
+ [str(emulator_bin), "-avd", avd_name, "-no-snapshot-load"],
1447
+ cwd=root,
1448
+ stdout=subprocess.DEVNULL,
1449
+ stderr=subprocess.DEVNULL,
1450
+ )
1451
+ out.success(f"Emulator {avd_name} starting (takes ~30s)")
1452
+ out.info(" Once booted, run: kctl-react cap install sfa")
1453
+ except Exception as e:
1454
+ out.error(f"Failed to start emulator: {e}")
1455
+ raise typer.Exit(1) from None
1456
+ return
1457
+
1458
+ # Just list
1459
+ rows = [[avd, "available"] for avd in avds]
1460
+ out.table(
1461
+ "Android Virtual Devices",
1462
+ [("AVD", "cyan"), ("Status", "green")],
1463
+ rows,
1464
+ data_for_json=[{"name": avd} for avd in avds],
1465
+ )