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.
- kctl_react/__init__.py +3 -0
- kctl_react/__main__.py +5 -0
- kctl_react/cli.py +201 -0
- kctl_react/commands/__init__.py +0 -0
- kctl_react/commands/a11y.py +78 -0
- kctl_react/commands/affected.py +170 -0
- kctl_react/commands/apps.py +353 -0
- kctl_react/commands/build.py +376 -0
- kctl_react/commands/bundle_cmd.py +217 -0
- kctl_react/commands/cap.py +1465 -0
- kctl_react/commands/clean.py +76 -0
- kctl_react/commands/codegen.py +491 -0
- kctl_react/commands/compliance.py +587 -0
- kctl_react/commands/config_cmd.py +368 -0
- kctl_react/commands/dashboard.py +163 -0
- kctl_react/commands/deploy.py +318 -0
- kctl_react/commands/deps.py +792 -0
- kctl_react/commands/dev.py +96 -0
- kctl_react/commands/docker_cmd.py +73 -0
- kctl_react/commands/doctor.py +170 -0
- kctl_react/commands/e2e.py +343 -0
- kctl_react/commands/env.py +155 -0
- kctl_react/commands/i18n.py +310 -0
- kctl_react/commands/lint.py +306 -0
- kctl_react/commands/maintenance.py +308 -0
- kctl_react/commands/monitor_cmd.py +50 -0
- kctl_react/commands/observe.py +34 -0
- kctl_react/commands/packages.py +129 -0
- kctl_react/commands/perf.py +762 -0
- kctl_react/commands/pipeline.py +289 -0
- kctl_react/commands/pwa.py +193 -0
- kctl_react/commands/scaffold.py +323 -0
- kctl_react/commands/security.py +660 -0
- kctl_react/commands/skill_cmd.py +54 -0
- kctl_react/commands/state.py +254 -0
- kctl_react/commands/test_cmd.py +418 -0
- kctl_react/commands/ui_audit.py +889 -0
- kctl_react/core/__init__.py +0 -0
- kctl_react/core/analyzers.py +200 -0
- kctl_react/core/callbacks.py +70 -0
- kctl_react/core/compliance/__init__.py +3 -0
- kctl_react/core/compliance/api_check/__init__.py +3 -0
- kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
- kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
- kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
- kctl_react/core/compliance/api_check/checks/naming.py +60 -0
- kctl_react/core/compliance/api_check/checks/params.py +44 -0
- kctl_react/core/compliance/api_check/checks/requests.py +57 -0
- kctl_react/core/compliance/api_check/checks/types.py +55 -0
- kctl_react/core/compliance/api_check/hooks.py +133 -0
- kctl_react/core/compliance/api_check/matcher.py +55 -0
- kctl_react/core/compliance/api_check/schema.py +151 -0
- kctl_react/core/compliance/api_health/__init__.py +35 -0
- kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
- kctl_react/core/compliance/api_health/checks/auth.py +72 -0
- kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
- kctl_react/core/compliance/api_health/checks/response.py +55 -0
- kctl_react/core/compliance/api_health/checks/timing.py +38 -0
- kctl_react/core/compliance/api_health/client.py +99 -0
- kctl_react/core/compliance/api_health/sampler.py +16 -0
- kctl_react/core/compliance/checks/__init__.py +47 -0
- kctl_react/core/compliance/checks/api.py +101 -0
- kctl_react/core/compliance/checks/codegen.py +94 -0
- kctl_react/core/compliance/checks/darkmode.py +57 -0
- kctl_react/core/compliance/checks/errors.py +68 -0
- kctl_react/core/compliance/checks/features.py +66 -0
- kctl_react/core/compliance/checks/i18n_check.py +105 -0
- kctl_react/core/compliance/checks/imports.py +86 -0
- kctl_react/core/compliance/checks/navigation.py +62 -0
- kctl_react/core/compliance/checks/practices.py +122 -0
- kctl_react/core/compliance/checks/providers.py +85 -0
- kctl_react/core/compliance/checks/pwa.py +101 -0
- kctl_react/core/compliance/checks/responsive.py +47 -0
- kctl_react/core/compliance/checks/scripts.py +85 -0
- kctl_react/core/compliance/checks/shadcn.py +51 -0
- kctl_react/core/compliance/checks/structure.py +76 -0
- kctl_react/core/compliance/checks/testing.py +83 -0
- kctl_react/core/compliance/checks/theme.py +92 -0
- kctl_react/core/compliance/checks/ui_standard.py +185 -0
- kctl_react/core/compliance/checks/vite.py +83 -0
- kctl_react/core/compliance/engine.py +87 -0
- kctl_react/core/compliance/exceptions_map.py +15 -0
- kctl_react/core/compliance/fixes/__init__.py +33 -0
- kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
- kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
- kctl_react/core/compliance/fixes/imports_fix.py +36 -0
- kctl_react/core/compliance/fixes/structure_fix.py +20 -0
- kctl_react/core/compliance/fixes/theme_fix.py +29 -0
- kctl_react/core/compliance/models.py +106 -0
- kctl_react/core/config.py +201 -0
- kctl_react/core/discovery.py +185 -0
- kctl_react/core/exceptions.py +17 -0
- kctl_react/core/git.py +146 -0
- kctl_react/core/history.py +121 -0
- kctl_react/core/output.py +5 -0
- kctl_react/core/plugins.py +13 -0
- kctl_react/core/runner.py +34 -0
- kctl_react/py.typed +0 -0
- kctl_react-0.6.2.dist-info/METADATA +17 -0
- kctl_react-0.6.2.dist-info/RECORD +102 -0
- kctl_react-0.6.2.dist-info/WHEEL +4 -0
- 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
|
+
)
|