lecodes-cli 0.18.2 → 0.19.2

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 (224) hide show
  1. package/dist/index.js +1866 -301
  2. package/package.json +14 -12
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk-types.json +1 -1
  5. package/src/api.ts +302 -0
  6. package/src/browserAuth.ts +87 -0
  7. package/src/cmgenTool.ts +104 -0
  8. package/src/commands/app.ts +891 -0
  9. package/src/commands/appAndroid.ts +603 -0
  10. package/src/commands/appDesktop.ts +326 -0
  11. package/src/commands/appDesktopMac.ts +470 -0
  12. package/src/commands/appIcon.ts +187 -0
  13. package/src/commands/appShared.ts +448 -0
  14. package/src/commands/appTemplates.ts +883 -0
  15. package/src/commands/appTemplatesAndroid.ts +599 -0
  16. package/src/commands/appTemplatesGradlew.ts +9 -0
  17. package/src/commands/assets.ts +28 -0
  18. package/src/commands/clone.ts +60 -0
  19. package/src/commands/compile.ts +253 -0
  20. package/src/commands/create.ts +59 -0
  21. package/src/commands/design.ts +863 -0
  22. package/src/commands/designTemplates.ts +11 -0
  23. package/src/commands/desktop.ts +122 -0
  24. package/src/commands/dev.ts +214 -0
  25. package/src/commands/diff.ts +59 -0
  26. package/src/commands/init.ts +191 -0
  27. package/src/commands/install.ts +198 -0
  28. package/src/commands/lightmap.ts +290 -0
  29. package/src/commands/link.ts +147 -0
  30. package/src/commands/login.ts +24 -0
  31. package/src/commands/navmesh.ts +225 -0
  32. package/src/commands/pn.ts +305 -0
  33. package/src/commands/projectTemplates.ts +129 -0
  34. package/src/commands/pull.ts +109 -0
  35. package/src/commands/push.ts +173 -0
  36. package/src/commands/render.ts +414 -0
  37. package/src/commands/scene.ts +148 -0
  38. package/src/commands/shaders.ts +157 -0
  39. package/src/commands/status.ts +29 -0
  40. package/src/commands/test.ts +370 -0
  41. package/src/commands/thumbs.ts +178 -0
  42. package/src/commands/types.ts +87 -0
  43. package/src/commands/update.ts +190 -0
  44. package/src/compile/assetIcons.ts +214 -0
  45. package/src/compile/collect.ts +46 -0
  46. package/src/compile/collectLocal.ts +37 -0
  47. package/src/compile/designCompile.ts +104 -0
  48. package/src/compile/fonts.ts +156 -0
  49. package/src/compile/headlessBundle.ts +129 -0
  50. package/src/compile/nativeStack.ts +21 -0
  51. package/src/compile/projectCompile.ts +109 -0
  52. package/src/compile/sceneCompile.ts +113 -0
  53. package/src/compile/screenEntry.ts +127 -0
  54. package/src/compile/shaders.ts +245 -0
  55. package/src/config.ts +42 -0
  56. package/src/designMeta.ts +35 -0
  57. package/src/desktopRenderer.ts +532 -0
  58. package/src/desktopScript.ts +276 -0
  59. package/src/dev/clientTemplates.ts +160 -0
  60. package/src/dev/devServer.ts +292 -0
  61. package/src/dev/wsServer.ts +144 -0
  62. package/src/distRoot.ts +20 -0
  63. package/src/ignore.ts +163 -0
  64. package/src/index.ts +491 -0
  65. package/src/lecodes-3d-editor.d.ts +41 -0
  66. package/src/lecodes-assets.d.ts +7 -0
  67. package/src/lecodes-design.d.ts +191 -0
  68. package/src/lecodes-renderer.d.ts +131 -0
  69. package/src/localFiles.ts +144 -0
  70. package/src/manifest.ts +46 -0
  71. package/src/matcTool.ts +137 -0
  72. package/src/peers.ts +40 -0
  73. package/src/project.ts +61 -0
  74. package/src/projectEnv.ts +94 -0
  75. package/src/qrcode-terminal.d.ts +9 -0
  76. package/src/releases.ts +125 -0
  77. package/src/serverDiff.ts +78 -0
  78. package/src/textDiff.ts +103 -0
  79. package/src/types.ts +0 -0
  80. package/src/util.ts +146 -0
  81. package/runtime/sdk/animate/animate.ts +0 -238
  82. package/runtime/sdk/animate/bezier.ts +0 -138
  83. package/runtime/sdk/animate/easings.ts +0 -126
  84. package/runtime/sdk/canvas/Canvas.ts +0 -305
  85. package/runtime/sdk/core/Aspect.ts +0 -512
  86. package/runtime/sdk/core/InspectorUI.ts +0 -212
  87. package/runtime/sdk/core/color.ts +0 -66
  88. package/runtime/sdk/core/compWrite.ts +0 -42
  89. package/runtime/sdk/core/events.ts +0 -38
  90. package/runtime/sdk/core/fields.ts +0 -120
  91. package/runtime/sdk/core/registry.ts +0 -23
  92. package/runtime/sdk/core/signals.ts +0 -277
  93. package/runtime/sdk/core/time.ts +0 -81
  94. package/runtime/sdk/g2/Camera2D.ts +0 -40
  95. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  96. package/runtime/sdk/g2/Node2D.ts +0 -267
  97. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  98. package/runtime/sdk/g2/Physics2D.ts +0 -346
  99. package/runtime/sdk/g2/Scene2D.ts +0 -209
  100. package/runtime/sdk/g2/Shape2D.ts +0 -259
  101. package/runtime/sdk/g2/Sprite.ts +0 -89
  102. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  103. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  104. package/runtime/sdk/g2/Texture2D.ts +0 -47
  105. package/runtime/sdk/g2/Tilemap.ts +0 -41
  106. package/runtime/sdk/g2/Tileset.ts +0 -71
  107. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  108. package/runtime/sdk/g2/autotile.ts +0 -433
  109. package/runtime/sdk/g2/cells.ts +0 -91
  110. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  111. package/runtime/sdk/g2/groups2d.ts +0 -106
  112. package/runtime/sdk/g2/loop.ts +0 -50
  113. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  114. package/runtime/sdk/g2/touch.ts +0 -83
  115. package/runtime/sdk/gl/Camera.ts +0 -160
  116. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  117. package/runtime/sdk/gl/CharacterController.ts +0 -238
  118. package/runtime/sdk/gl/Gearbox.ts +0 -212
  119. package/runtime/sdk/gl/Geometry.ts +0 -279
  120. package/runtime/sdk/gl/IK.ts +0 -193
  121. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  122. package/runtime/sdk/gl/Light.ts +0 -99
  123. package/runtime/sdk/gl/Lightmap.ts +0 -179
  124. package/runtime/sdk/gl/Material.ts +0 -245
  125. package/runtime/sdk/gl/Mesh.ts +0 -83
  126. package/runtime/sdk/gl/Model.ts +0 -64
  127. package/runtime/sdk/gl/Node.ts +0 -350
  128. package/runtime/sdk/gl/Noise.ts +0 -30
  129. package/runtime/sdk/gl/Particles.ts +0 -676
  130. package/runtime/sdk/gl/Physics.ts +0 -222
  131. package/runtime/sdk/gl/Plane.ts +0 -53
  132. package/runtime/sdk/gl/Ray.ts +0 -16
  133. package/runtime/sdk/gl/Scene.ts +0 -479
  134. package/runtime/sdk/gl/Shape.ts +0 -377
  135. package/runtime/sdk/gl/Texture.ts +0 -46
  136. package/runtime/sdk/gl/Trigger.ts +0 -45
  137. package/runtime/sdk/gl/Vehicle.ts +0 -473
  138. package/runtime/sdk/gl/Wheel.ts +0 -240
  139. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  140. package/runtime/sdk/gl/animation/Animator.ts +0 -87
  141. package/runtime/sdk/gl/animation/Layer.ts +0 -29
  142. package/runtime/sdk/gl/animation/Loop.ts +0 -25
  143. package/runtime/sdk/gl/animation/Playback.ts +0 -43
  144. package/runtime/sdk/gl/animation/core.ts +0 -294
  145. package/runtime/sdk/gl/controls.ts +0 -95
  146. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  147. package/runtime/sdk/gl/scenarios.ts +0 -291
  148. package/runtime/sdk/gl/state.ts +0 -6
  149. package/runtime/sdk/gl/touch.ts +0 -68
  150. package/runtime/sdk/inject.ts +0 -186
  151. package/runtime/sdk/math/Mathf.ts +0 -118
  152. package/runtime/sdk/math/mat4.ts +0 -278
  153. package/runtime/sdk/math/quat.ts +0 -232
  154. package/runtime/sdk/math/vec.ts +0 -255
  155. package/runtime/sdk/plugins/camera.ts +0 -81
  156. package/runtime/sdk/plugins/geolocation.ts +0 -123
  157. package/runtime/sdk/plugins/oauth.ts +0 -61
  158. package/runtime/sdk/plugins/permission.ts +0 -7
  159. package/runtime/sdk/plugins/push.ts +0 -132
  160. package/runtime/sdk/plugins/qr.ts +0 -73
  161. package/runtime/sdk/plugins/service.ts +0 -47
  162. package/runtime/sdk/runtime/app.ts +0 -101
  163. package/runtime/sdk/runtime/appEvents.ts +0 -54
  164. package/runtime/sdk/runtime/channel.ts +0 -61
  165. package/runtime/sdk/runtime/clipboard.ts +0 -20
  166. package/runtime/sdk/runtime/datetime.ts +0 -329
  167. package/runtime/sdk/runtime/device.ts +0 -293
  168. package/runtime/sdk/runtime/fetch.ts +0 -77
  169. package/runtime/sdk/runtime/files.ts +0 -23
  170. package/runtime/sdk/runtime/input.ts +0 -175
  171. package/runtime/sdk/runtime/media.ts +0 -111
  172. package/runtime/sdk/runtime/misc.ts +0 -16
  173. package/runtime/sdk/runtime/net.ts +0 -36
  174. package/runtime/sdk/runtime/rpc.ts +0 -218
  175. package/runtime/sdk/runtime/service.ts +0 -83
  176. package/runtime/sdk/runtime/share.ts +0 -9
  177. package/runtime/sdk/runtime/storage.ts +0 -13
  178. package/runtime/sdk/runtime/touch.ts +0 -76
  179. package/runtime/sdk/scene/defineScene.ts +0 -1227
  180. package/runtime/sdk/scene/editorPlugins.ts +0 -92
  181. package/runtime/sdk/scene/gizmos.ts +0 -148
  182. package/runtime/sdk/scene/grammar.ts +0 -120
  183. package/runtime/sdk/scene/material.ts +0 -188
  184. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  185. package/runtime/sdk/server/auth/global.ts +0 -80
  186. package/runtime/sdk/server/auth/host.ts +0 -318
  187. package/runtime/sdk/server/auth/models.ts +0 -83
  188. package/runtime/sdk/server/auth/types.ts +0 -50
  189. package/runtime/sdk/server/channel.ts +0 -56
  190. package/runtime/sdk/server/context.ts +0 -36
  191. package/runtime/sdk/server/db/defineDb.ts +0 -237
  192. package/runtime/sdk/server/db/fields.ts +0 -132
  193. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  194. package/runtime/sdk/server/db/index.ts +0 -7
  195. package/runtime/sdk/server/db/marci/query.ts +0 -412
  196. package/runtime/sdk/server/db/types.ts +0 -202
  197. package/runtime/sdk/server/errors.ts +0 -12
  198. package/runtime/sdk/server/host.ts +0 -74
  199. package/runtime/sdk/server/inject.ts +0 -13
  200. package/runtime/sdk/server/runtime.ts +0 -133
  201. package/runtime/sdk/server/validate.ts +0 -87
  202. package/runtime/sdk/ui/NativeView.ts +0 -142
  203. package/runtime/sdk/ui/UI.ts +0 -39
  204. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  205. package/runtime/sdk/ui/UIButton.ts +0 -101
  206. package/runtime/sdk/ui/UIContainer.ts +0 -60
  207. package/runtime/sdk/ui/UIImage.ts +0 -83
  208. package/runtime/sdk/ui/UIInput.ts +0 -185
  209. package/runtime/sdk/ui/UIModal.ts +0 -139
  210. package/runtime/sdk/ui/UINode.ts +0 -826
  211. package/runtime/sdk/ui/UIPager.ts +0 -362
  212. package/runtime/sdk/ui/UIPopover.ts +0 -100
  213. package/runtime/sdk/ui/UIScreen.ts +0 -123
  214. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  215. package/runtime/sdk/ui/UISpacer.ts +0 -14
  216. package/runtime/sdk/ui/UITabs.ts +0 -236
  217. package/runtime/sdk/ui/UIText.ts +0 -51
  218. package/runtime/sdk/ui/UIVideo.ts +0 -88
  219. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  220. package/runtime/sdk/ui/UIWidget.ts +0 -127
  221. package/runtime/sdk/ui/fonts.ts +0 -13
  222. package/runtime/sdk/ui/presentable.ts +0 -117
  223. package/runtime/sdk/ui/router.ts +0 -132
  224. package/runtime/sdk/ui/theme.ts +0 -84
@@ -0,0 +1,603 @@
1
+ import { execFileSync, spawn, spawnSync } from "node:child_process"
2
+ import { createHash } from "node:crypto"
3
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
4
+ import { homedir } from "node:os"
5
+ import { basename, dirname, join } from "node:path"
6
+ import { readManifest } from "../manifest"
7
+ import { compileShell, type BundleResource } from "./compile"
8
+ import { CliError, c, flagBool, flagStr, log, note, success, warn, type Args } from "../util"
9
+ import {
10
+ type AppConfig, SDK_VARIANTS, VERSION_RE, cmpVersion, ensureShellIgnored, fetchLatestSdkVersion,
11
+ projectRoot, readJson, resolvePlugin, resolveSdkVersion, resolveVariant, runTool, slugify,
12
+ writeJson, type LockEntry,
13
+ } from "./appShared"
14
+ import { loadIconMaster, restampManifestIcon, writeAndroidIcons } from "./appIcon"
15
+ import {
16
+ ANDROID_GITIGNORE, ANDROID_README, COLORS_XML, GRADLE_PROPERTIES, GRADLE_VERSION,
17
+ GRADLE_PLUGIN_VERSIONS, IC_LAUNCHER_XML, aarWrapperBuildGradle, appBuildGradleTemplate,
18
+ appManifestTemplate, appViewModelTemplate, gradleWrapperProperties, mainActivityTemplate,
19
+ pluginBuildGradleTemplate, rootBuildGradleTemplate, runtimeBuildGradleTemplate,
20
+ runtimeKotlinTemplate, settingsGradleTemplate, stringsXmlTemplate, themesXmlTemplate,
21
+ type AndroidRuntimePlugin, type AndroidShellIdentity,
22
+ } from "./appTemplatesAndroid"
23
+ import { GRADLEW_BAT, GRADLEW_SH } from "./appTemplatesGradlew"
24
+ import { type SdkVariant } from "./appTemplates"
25
+
26
+ /* The Android arm of `lecodes app` — the mirror of the iOS flows in app.ts, over Gradle
27
+ * instead of Xcode. Same ownership contract (see appTemplatesAndroid.ts); the SDK is the
28
+ * lecodes-android fat AAR published as GitHub Release assets on the dist repo below and
29
+ * resolved by Gradle through an ivy repository over the release-download URL pattern. */
30
+
31
+ export const ANDROID_SDK_REPO = "letary/lecodes-android-sdk"
32
+
33
+ /** Oldest lecodes-android-sdk a generated shell can use — 1.2.0 moved system-back wiring into
34
+ * engine.attach() and the template dropped its own dispatcher block, so an older SDK under
35
+ * this template would leave the back button dead. */
36
+ export const ANDROID_SDK_MIN_VERSION = "1.2.0"
37
+
38
+ /** First lecodes-android-sdk release that publishes the variant matrix (lecodes-sdk-3d / -2d /
39
+ * -core next to the full lecodes-sdk asset). Older pins always link the full artifact. */
40
+ export const ANDROID_SDK_VARIANTS_MIN = "1.3.0"
41
+
42
+ /** Release-asset / ivy artifact name for an SDK variant — full keeps the historic plain name so
43
+ * pre-variant pins and shells keep resolving. */
44
+ const variantArtifact = (variant: SdkVariant): string =>
45
+ variant === "full" ? "lecodes-sdk" : `lecodes-sdk-${variant}`
46
+
47
+ const ANDROID_SDK_URL_DEFAULT = `https://github.com/${ANDROID_SDK_REPO}/releases/download`
48
+
49
+ /** The ivy base URL shells resolve the SDK AAR from. `LECODES_ANDROID_SDK_URL` overrides for
50
+ * the pre-publish dev loop (serve a staged AAR from a local static server). */
51
+ const sdkRepoUrl = () => process.env.LECODES_ANDROID_SDK_URL ?? ANDROID_SDK_URL_DEFAULT
52
+
53
+ export const ANDROID_SDK_OPTS = { repo: ANDROID_SDK_REPO, minVersion: ANDROID_SDK_MIN_VERSION, configKey: "android.sdk" }
54
+
55
+ /** The SDK version a synced shell currently links — the CLI-owned runtime build file's pin is
56
+ * the truth (the offline fallback, like Package.swift on iOS). */
57
+ export const shellSdkVersionAndroid = (androidDir: string): string | null => {
58
+ const path = join(androidDir, "lecodes-runtime", "build.gradle.kts")
59
+ if (!existsSync(path)) return null
60
+ const m = /io\.letary:lecodes-sdk(?:-(?:core|2d|3d))?:(\d+\.\d+\.\d+)@aar/.exec(readFileSync(path, "utf8"))
61
+ return m ? m[1] : null
62
+ }
63
+
64
+ /* ------------------------------------ gradle wrapper ------------------------------------ */
65
+
66
+ // The wrapper LAUNCHER jar (version-agnostic — the real distribution comes from
67
+ // gradle-wrapper.properties), pinned to the gradle/gradle v8.13.0 tag and hash-verified.
68
+ const GRADLE_WRAPPER_JAR_URL = "https://raw.githubusercontent.com/gradle/gradle/v8.13.0/gradle/wrapper/gradle-wrapper.jar"
69
+ const GRADLE_WRAPPER_JAR_SHA256 = "81a82aaea5abcc8ff68b3dfcb58b3c3c429378efd98e7433460610fecd7ae45f"
70
+
71
+ /** Fetch-and-cache gradle-wrapper.jar (~/.lecodes/gradle-wrapper/<ver>/). Null on failure —
72
+ * init degrades with a warning instead of failing (Android Studio can repair a missing jar). */
73
+ const ensureGradleWrapperJar = async (): Promise<Uint8Array | null> => {
74
+ const cached = join(homedir(), ".lecodes", "gradle-wrapper", GRADLE_VERSION, "gradle-wrapper.jar")
75
+ if (existsSync(cached)) return new Uint8Array(readFileSync(cached))
76
+ try {
77
+ const res = await fetch(GRADLE_WRAPPER_JAR_URL)
78
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
79
+ const bytes = new Uint8Array(await res.arrayBuffer())
80
+ const hash = createHash("sha256").update(bytes).digest("hex")
81
+ if (hash !== GRADLE_WRAPPER_JAR_SHA256) throw new Error(`sha256 mismatch (${hash})`)
82
+ mkdirSync(dirname(cached), { recursive: true })
83
+ writeFileSync(cached, bytes)
84
+ return bytes
85
+ } catch (e) {
86
+ warn(`Could not fetch gradle-wrapper.jar (${(e as Error).message}) — run \`gradle wrapper --gradle-version ${GRADLE_VERSION}\` inside android/, or open the project in Android Studio (it offers to repair the wrapper).`)
87
+ return null
88
+ }
89
+ }
90
+
91
+ /** Windows file locks (Android Studio's daemon) make first-try rmdir flaky — retry. */
92
+ const rmrf = (path: string) => rmSync(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
93
+
94
+ /* ---------------------------------------- init ---------------------------------------- */
95
+
96
+ export const initAndroid = async (args: Args) => {
97
+ const root = projectRoot()
98
+ const androidDir = join(root, "android")
99
+ if (existsSync(androidDir)) {
100
+ throw new CliError("android/ already exists — run `lecodes app sync` to update it.")
101
+ }
102
+
103
+ const existing = readJson<AppConfig>(join(root, "app.json")) ?? {}
104
+
105
+ const variant = flagStr(args, "variant") ?? existing.android?.variant ?? "auto"
106
+ if (variant !== "auto" && !SDK_VARIANTS.includes(variant as SdkVariant)) {
107
+ throw new CliError(`--variant must be auto, ${SDK_VARIANTS.join(", ")} (got "${variant}")`)
108
+ }
109
+
110
+ // The native SDK: a new shell starts on the newest published release, pinned into app.json so
111
+ // builds stay reproducible (`lecodes app update` moves the pin). `--sdk latest` opts into
112
+ // re-resolving on every sync instead.
113
+ const sdkFlag = flagStr(args, "sdk") ?? existing.android?.sdk
114
+ const sdkVersion = await resolveSdkVersion(process.env.LECODES_ANDROID_SDK ?? sdkFlag ?? "latest", null, ANDROID_SDK_OPTS)
115
+ const sdk = sdkFlag === "latest" ? "latest" : sdkVersion
116
+
117
+ const name = flagStr(args, "name") ?? existing.name ?? basename(root)
118
+ // Dashes are legal in a slug but not in a Java package — strip them for the default id.
119
+ const defaultAppId = `com.example.${slugify(name).replace(/-/g, "")}`
120
+ const config: AppConfig = {
121
+ ...existing,
122
+ name,
123
+ version: existing.version ?? "1.0.0",
124
+ plugins: existing.plugins ?? {},
125
+ android: {
126
+ applicationId: flagStr(args, "app-id") ?? existing.android?.applicationId ?? defaultAppId,
127
+ versionCode: existing.android?.versionCode ?? 1,
128
+ sdk,
129
+ variant,
130
+ ...(existing.android?.signing ? { signing: existing.android.signing } : {}),
131
+ },
132
+ }
133
+ writeJson(join(root, "app.json"), config)
134
+ log(`${c.green("written")} app.json`)
135
+
136
+ // Never push the shell to the platform.
137
+ ensureShellIgnored(root, "android")
138
+
139
+ const identity: AndroidShellIdentity = {
140
+ displayName: name,
141
+ applicationId: config.android!.applicationId!,
142
+ version: config.version!,
143
+ versionCode: config.android!.versionCode!,
144
+ }
145
+ const pkgPath = identity.applicationId.replace(/\./g, "/")
146
+
147
+ const write = (rel: string, content: string | Uint8Array) => {
148
+ const abs = join(androidDir, rel)
149
+ mkdirSync(dirname(abs), { recursive: true })
150
+ writeFileSync(abs, content)
151
+ log(`${c.green("created")} android/${rel}`)
152
+ }
153
+
154
+ write("README.md", ANDROID_README)
155
+ write(".gitignore", ANDROID_GITIGNORE)
156
+ write("settings.gradle.kts", settingsGradleTemplate(slugify(name), [], [], sdkRepoUrl()))
157
+ write("build.gradle.kts", rootBuildGradleTemplate([]))
158
+ write("gradle.properties", GRADLE_PROPERTIES)
159
+ write("gradle/wrapper/gradle-wrapper.properties", gradleWrapperProperties())
160
+ // gradlew LF / gradlew.bat CRLF — load-bearing for sh resp. cmd.
161
+ write("gradlew", GRADLEW_SH)
162
+ write("gradlew.bat", GRADLEW_BAT.replace(/\n/g, "\r\n"))
163
+ if (process.platform !== "win32") chmodSync(join(androidDir, "gradlew"), 0o755)
164
+ const wrapperJar = await ensureGradleWrapperJar()
165
+ if (wrapperJar) write("gradle/wrapper/gradle-wrapper.jar", wrapperJar)
166
+
167
+ write("app/build.gradle.kts", appBuildGradleTemplate(identity))
168
+ write("app/src/main/AndroidManifest.xml", appManifestTemplate())
169
+ write(`app/src/main/java/${pkgPath}/MainActivity.kt`, mainActivityTemplate(identity.applicationId))
170
+ write(`app/src/main/java/${pkgPath}/AppViewModel.kt`, appViewModelTemplate(identity.applicationId))
171
+ write("app/src/main/res/values/themes.xml", themesXmlTemplate())
172
+ write("app/src/main/res/values/colors.xml", COLORS_XML)
173
+ write("app/src/main/res/values/strings.xml", stringsXmlTemplate(name))
174
+ write("app/src/main/res/drawable/ic_launcher.xml", IC_LAUNCHER_XML)
175
+ // Like iOS init: no compiled bundle yet, so an "auto" variant starts on core — the first sync
176
+ // re-resolves from what the bundle actually uses. Pre-variant SDK pins get the full AAR.
177
+ const initVariant: SdkVariant = variant === "auto" ? "core" : variant as SdkVariant
178
+ const initArtifact = cmpVersion(sdkVersion, ANDROID_SDK_VARIANTS_MIN) >= 0 ? variantArtifact(initVariant) : "lecodes-sdk"
179
+ write("lecodes-runtime/build.gradle.kts", runtimeBuildGradleTemplate(sdkVersion, [], initArtifact))
180
+ write("lecodes-runtime/src/main/kotlin/io/letary/lecodes/runtime/LeCodesRuntime.kt", runtimeKotlinTemplate([]))
181
+
182
+ log("")
183
+ log(`${c.green("sdk")} lecodes-android-sdk ${sdkVersion}${sdk === "latest" ? c.dim(" (android.sdk: latest — re-resolved on every sync)") : c.dim(" (pinned in app.json android.sdk — `lecodes app update` moves it)")}`)
184
+ success(`Android shell for ${c.bold(name)} ready.`)
185
+ log(` ${c.bold("lecodes app sync")} embed the compiled bundle (+ plugins) into the shell`)
186
+ log(` ${c.bold("lecodes app open android")} then run on your device from Android Studio`)
187
+ log(` App icon: set app.json "icon" to a square ≥1024px image — sync renders every size (or use Android Studio → Image Asset)`)
188
+ }
189
+
190
+ /* ---------------------------------------- sync ---------------------------------------- */
191
+
192
+ export interface SyncArtifacts { bundleJs: string, resources: BundleResource[] }
193
+
194
+ /** One manifest gradlePlugins entry → { id, version } via the known-version table
195
+ * (bare id) or an explicit "<id>:<version>" pin. */
196
+ const resolveGradlePlugin = (pluginId: string, entry: string): { id: string, version: string } => {
197
+ const at = entry.lastIndexOf(":")
198
+ if (at > 0 && VERSION_RE.test(entry.slice(at + 1))) {
199
+ return { id: entry.slice(0, at), version: entry.slice(at + 1) }
200
+ }
201
+ const version = GRADLE_PLUGIN_VERSIONS[entry]
202
+ if (!version) {
203
+ throw new CliError(`Plugin "${pluginId}" requests unknown Gradle plugin "${entry}" — pin it as "<id>:<version>" in the manifest.`)
204
+ }
205
+ return { id: entry, version }
206
+ }
207
+
208
+ export const syncAndroid = async (root: string, config: AppConfig, prior: SyncArtifacts | null): Promise<void> => {
209
+ const androidDir = join(root, "android")
210
+
211
+ // The native SDK release this shell links: app.json android.sdk, or whatever the runtime
212
+ // build file already names, so a routine sync never moves a shell's SDK on its own.
213
+ const linkedSdk = shellSdkVersionAndroid(androidDir)
214
+ const sdkPin = process.env.LECODES_ANDROID_SDK ?? config.android?.sdk ?? linkedSdk ?? "latest"
215
+ const sdkVersion = await resolveSdkVersion(sdkPin, linkedSdk, ANDROID_SDK_OPTS)
216
+
217
+ // ---- bundle: compiled FIRST (into assets/, the one CLI-owned dir inside app/) — the SDK
218
+ // variant is read from its header, and the plugin build files generated below must pin the
219
+ // same SDK artifact as lecodes-runtime (two variant coordinates in one shell would put two
220
+ // copies of the SDK classes on the runtime classpath) ----
221
+ const remotePatterns = config.resources?.remote ?? []
222
+ if (!Array.isArray(remotePatterns) || remotePatterns.some((p) => typeof p !== "string")) {
223
+ throw new CliError('app.json "resources.remote" must be an array of glob strings (e.g. ["videos/**"]).')
224
+ }
225
+ const assetsDir = join(androidDir, "app", "src", "main", "assets")
226
+ rmrf(assetsDir)
227
+ mkdirSync(assetsDir, { recursive: true })
228
+ let bundleJs: string
229
+ let resources: BundleResource[]
230
+ if (prior) {
231
+ // The iOS pass of this same sync already compiled — reuse byte-identical output.
232
+ writeFileSync(join(assetsDir, "app.js"), prior.bundleJs)
233
+ bundleJs = prior.bundleJs
234
+ resources = prior.resources
235
+ } else {
236
+ resources = await compileShell({ _: [], flags: { o: join(assetsDir, "app.js") } }, { remotePatterns })
237
+ bundleJs = readFileSync(join(assetsDir, "app.js"), "utf8")
238
+ }
239
+
240
+ // ---- variant: the smallest SDK artifact covering what the bundle uses (or the forced one) ----
241
+ const requestedVariant = config.android?.variant ?? "auto"
242
+ if (requestedVariant !== "auto" && !SDK_VARIANTS.includes(requestedVariant as SdkVariant)) {
243
+ throw new CliError(`app.json android.variant must be "auto" or one of ${SDK_VARIANTS.join(", ")} (got "${requestedVariant}").`)
244
+ }
245
+ const { variant, detected } = resolveVariant(requestedVariant, bundleJs, "android.variant")
246
+ let sdkArtifact = variantArtifact(variant)
247
+ if (sdkArtifact !== "lecodes-sdk" && cmpVersion(sdkVersion, ANDROID_SDK_VARIANTS_MIN) < 0) {
248
+ warn(`lecodes-android-sdk ${sdkVersion} predates the variant matrix (${ANDROID_SDK_VARIANTS_MIN}) — linking the full lecodes-sdk AAR; run \`lecodes app update\` to pick up variant builds.`)
249
+ sdkArtifact = "lecodes-sdk"
250
+ }
251
+ const WHY: Record<SdkVariant, string> = {
252
+ full: "3D + 2D engines in use", "3d": "3D in use, no 2D", "2d": "2D in use, no 3D", core: "no 3D/2D engine use detected",
253
+ }
254
+ log(`${c.green("variant")} ${variant} → ${sdkArtifact}${requestedVariant === "auto" ? c.dim(` (auto: ${WHY[detected]})`) : c.dim(" (forced by android.variant)")}`)
255
+
256
+ // ---- plugins: vendor sources as Gradle modules, generate their build files, pin the lock ----
257
+ const lockPath = join(root, "plugins.lock.json")
258
+ const oldLock = readJson<{ plugins?: Record<string, LockEntry> }>(lockPath)?.plugins ?? {}
259
+ const pluginSpecs = Object.entries(config.plugins ?? {})
260
+ const runtimePlugins: AndroidRuntimePlugin[] = []
261
+ const gradlePlugins = new Map<string, string>() // id → version
262
+ const aarWrapperPaths: string[] = [] // ":plugins:<module>:libs:<name>"
263
+ const newLock: Record<string, LockEntry> = {}
264
+
265
+ const pluginsDir = join(androidDir, "plugins")
266
+ for (const [id, spec] of pluginSpecs) {
267
+ const plugin = resolvePlugin(id, spec, oldLock[id], "android")
268
+ const android = plugin.manifest.android!
269
+ const module = android.module
270
+ const register = android.register
271
+ if (!module || !register) throw new CliError(`Plugin "${id}": manifest android.module / android.register missing`)
272
+
273
+ const src = join(plugin.dir, android.sources ?? "android")
274
+ if (!existsSync(src)) throw new CliError(`Plugin "${id}": sources dir ${src} missing`)
275
+ const dest = join(pluginsDir, module)
276
+ rmrf(dest)
277
+ cpSync(src, dest, { recursive: true })
278
+
279
+ // Hard error, not a warning: below minSdk the vendored sources reference SDK API that
280
+ // does not exist in the linked release, so the shell cannot compile anyway.
281
+ if (android.minSdk && VERSION_RE.test(android.minSdk) && cmpVersion(sdkVersion, android.minSdk) < 0) {
282
+ throw new CliError(`Plugin "${id}" needs lecodes-android-sdk ${android.minSdk} but the shell links ${sdkVersion} — run \`lecodes app update\` (or set app.json android.sdk).`)
283
+ }
284
+ for (const entry of android.gradlePlugins ?? []) {
285
+ const resolved = resolveGradlePlugin(id, entry)
286
+ gradlePlugins.set(resolved.id, resolved.version)
287
+ }
288
+
289
+ // The generated module build file: namespace from the register FQN's package, local AARs
290
+ // and consumer rules by convention, Maven deps from the manifest. Each local AAR moves
291
+ // into a generated wrapper module (AGP forbids direct local .aar file deps in libraries).
292
+ const namespace = register.split(".").slice(0, -1).join(".")
293
+ const libsDir = join(dest, "libs")
294
+ const aarNames = existsSync(libsDir) ? readdirSync(libsDir).filter(f => f.endsWith(".aar")) : []
295
+ for (const aar of aarNames) {
296
+ const base = aar.replace(/\.aar$/, "")
297
+ const wrapDir = join(libsDir, base)
298
+ mkdirSync(wrapDir, { recursive: true })
299
+ renameSync(join(libsDir, aar), join(wrapDir, aar))
300
+ writeFileSync(join(wrapDir, "build.gradle.kts"), aarWrapperBuildGradle(aar))
301
+ aarWrapperPaths.push(`:plugins:${module}:libs:${base}`)
302
+ }
303
+ const hasConsumerRules = existsSync(join(dest, "consumer-rules.pro"))
304
+ writeFileSync(join(dest, "build.gradle.kts"),
305
+ pluginBuildGradleTemplate(id, module, namespace, sdkVersion, android.dependencies ?? [], aarNames, hasConsumerRules, sdkArtifact))
306
+
307
+ runtimePlugins.push({ id, module, register })
308
+ newLock[id] = plugin.lock
309
+ log(`${c.green("plugin")} ${id}@${plugin.lock.version} (${plugin.lock.tag} → plugins/${module})`)
310
+ }
311
+
312
+ // Remove modules of uninstalled plugins.
313
+ const keep = new Set(runtimePlugins.map(p => p.module))
314
+ for (const entry of existsSync(pluginsDir) ? readdirSync(pluginsDir) : []) {
315
+ if (!keep.has(entry) && statSync(join(pluginsDir, entry)).isDirectory()) {
316
+ rmrf(join(pluginsDir, entry))
317
+ log(`${c.yellow("removed")} android/plugins/${entry} (plugin uninstalled)`)
318
+ }
319
+ }
320
+
321
+ const sdkWhy = sdkPin === "latest" ? "latest" : config.android?.sdk ? "pinned by android.sdk" : "from the shell — set android.sdk or run `lecodes app update`"
322
+ log(`${c.green("sdk")} lecodes-android-sdk ${sdkVersion}${c.dim(` (${sdkWhy})`)}`)
323
+
324
+ // Embed exactly what the bundle resolves locally (the `// preload:` header), flat by lookup
325
+ // name — the host reads assets/ by basename. Unlike iOS there is no separate SDK resources
326
+ // bundle to download: engine assets ride inside the variant AAR itself (GL-less variants
327
+ // simply ship without the Filament assets).
328
+ const preload = /^\/\/ preload: (.*)$/m.exec(bundleJs)?.[1].split(";").map((s) => s.trim()).filter(Boolean) ?? []
329
+ const IMPLICIT_PRELOADS = new Set(["uberarchive", "capture"]) // engine-synthesized, not project files
330
+ const byName = new Map<string, BundleResource[]>()
331
+ for (const r of resources) {
332
+ if (r.remote || !r.absPath) continue
333
+ const list = byName.get(r.name) ?? []
334
+ list.push(r)
335
+ byName.set(r.name, list)
336
+ }
337
+ let embedded = 0
338
+ for (const name of preload) {
339
+ if (IMPLICIT_PRELOADS.has(name)) continue
340
+ const candidates = byName.get(name) ?? []
341
+ if (candidates.length === 0) {
342
+ warn(`bundle needs local asset "${name}" but no project file provides it — it won't load on device`)
343
+ continue
344
+ }
345
+ if (candidates.length > 1) warn(`asset name collision: ${name} (${candidates.map((r) => r.path).join(", ")}) — embedding ${candidates[0].path}`)
346
+ cpSync(candidates[0].absPath!, join(assetsDir, name))
347
+ embedded++
348
+ }
349
+ if (embedded > 0) log(`${c.green("assets")} ${embedded} file(s) embedded → android/app/src/main/assets/`)
350
+ const streamed = resources.filter((r) => r.remote)
351
+ if (streamed.length > 0) log(`${c.green("remote")} ${streamed.length} resource(s) stay on the server (resources.remote): ${streamed.map((r) => r.path).join(", ")}`)
352
+
353
+ // ---- identity: bake the project uuid as world identity (every published
354
+ // lecodes-android-sdk release carries bootProjectUuid — no version gate needed) ----
355
+ let shellUuid: string | undefined
356
+ try { shellUuid = readManifest(root).uuid } catch { /* local-only project — unattributed */ }
357
+ if (shellUuid) log(`${c.green("identity")} project ${shellUuid} baked into the shell (world identity)`)
358
+
359
+ // ---- regenerate the CLI-owned files ----
360
+ const modules = runtimePlugins.map(p => p.module)
361
+ writeFileSync(join(androidDir, "README.md"), ANDROID_README)
362
+ writeFileSync(join(androidDir, "settings.gradle.kts"), settingsGradleTemplate(slugify(config.name ?? basename(root)), modules, aarWrapperPaths, sdkRepoUrl()))
363
+ writeFileSync(join(androidDir, "build.gradle.kts"), rootBuildGradleTemplate([...gradlePlugins].map(([id, version]) => ({ id, version }))))
364
+ writeFileSync(join(androidDir, "gradle.properties"), GRADLE_PROPERTIES)
365
+ writeFileSync(join(androidDir, "lecodes-runtime", "build.gradle.kts"), runtimeBuildGradleTemplate(sdkVersion, modules, sdkArtifact))
366
+ const runtimeKt = join(androidDir, "lecodes-runtime", "src", "main", "kotlin", "io", "letary", "lecodes", "runtime", "LeCodesRuntime.kt")
367
+ mkdirSync(dirname(runtimeKt), { recursive: true })
368
+ writeFileSync(runtimeKt, runtimeKotlinTemplate(runtimePlugins, shellUuid))
369
+
370
+ if (pluginSpecs.length > 0 || existsSync(lockPath)) writeJson(lockPath, { plugins: newLock })
371
+
372
+ // ---- user-file touch-ups (app/build.gradle.kts is USER-owned): values-only version
373
+ // restamps + the `// lecodes:plugin`-tagged Gradle-plugin lines ----
374
+ restampAppBuildGradle(androidDir, config, gradlePlugins)
375
+
376
+ // ---- icons: rendered from app.json "icon" when set (a CLI-owned carve-out inside the
377
+ // user-owned res/, like assets/) — absent, hand-made icons stay untouched ----
378
+ if (config.icon) {
379
+ await writeAndroidIcons(androidDir, await loadIconMaster(root, config))
380
+ restampManifestIcon(androidDir)
381
+ }
382
+
383
+ // ---- OTA: not on Android yet ----
384
+ if (config.update) {
385
+ note(`app.json "update" is iOS-only for now — the Android runtime has no native updater, so it's ignored for this shell.`)
386
+ }
387
+
388
+ log("")
389
+ success(`Android shell synced: app.js + ${runtimePlugins.length} plugin(s), SDK ${sdkVersion}, v${config.version ?? "1.0.0"} (${config.android?.versionCode ?? 1}).`)
390
+ note("Build & run from Android Studio (`lecodes app open android`) — user files in android/app/ were not touched.")
391
+ }
392
+
393
+ /** Values-only restamps + tagged-line management in the USER-owned app/build.gradle.kts —
394
+ * the Android analog of the pbxproj MARKETING_VERSION restamp + CODE_SIGN_ENTITLEMENTS
395
+ * injection: warn-not-fail when the user restructured, and the ONLY lines sync ever removes
396
+ * are its own `// lecodes:plugin`-tagged ones. */
397
+ const restampAppBuildGradle = (androidDir: string, config: AppConfig, gradlePlugins: Map<string, string>) => {
398
+ const path = join(androidDir, "app", "build.gradle.kts")
399
+ if (!existsSync(path)) {
400
+ warn("android/app/build.gradle.kts is missing — run `lecodes app init android` on a clean tree to regenerate the shell.")
401
+ return
402
+ }
403
+ let content = readFileSync(path, "utf8")
404
+ const before = content
405
+
406
+ const versionName = config.version ?? "1.0.0"
407
+ const versionCode = config.android?.versionCode ?? 1
408
+ if (/versionName = "[^"]*"/.test(content)) {
409
+ content = content.replace(/versionName = "[^"]*"/, `versionName = "${versionName}"`)
410
+ } else {
411
+ warn(`couldn't restamp versionName in app/build.gradle.kts — set it to "${versionName}" yourself.`)
412
+ }
413
+ if (/versionCode = \d+/.test(content)) {
414
+ content = content.replace(/versionCode = \d+/, `versionCode = ${versionCode}`)
415
+ } else {
416
+ warn(`couldn't restamp versionCode in app/build.gradle.kts — set it to ${versionCode} yourself.`)
417
+ }
418
+
419
+ // Which requested Gradle plugins actually get applied: google-services only once the app has
420
+ // its google-services.json (the plugin hard-fails the build without it).
421
+ const applied = new Set<string>()
422
+ for (const id of gradlePlugins.keys()) {
423
+ if (id === "com.google.gms.google-services" && !existsSync(join(androidDir, "app", "google-services.json"))) {
424
+ warn(`push needs android/app/google-services.json (download it from the le.codes Firebase project — the app's package name must be registered there). The google-services Gradle plugin stays un-applied until the file exists, so FCM registration will fail.`)
425
+ continue
426
+ }
427
+ applied.add(id)
428
+ }
429
+
430
+ // Remove tagged lines that are no longer required, add missing ones inside the first
431
+ // plugins { } block.
432
+ content = content.split("\n").filter(line => {
433
+ const m = /^\s*id\("([^"]+)"\)\s*\/\/ lecodes:plugin\s*$/.exec(line)
434
+ return !(m && !applied.has(m[1]))
435
+ }).join("\n")
436
+ for (const id of applied) {
437
+ if (new RegExp(`id\\("${id.replace(/\./g, "\\.")}"\\)`).test(content)) continue
438
+ const start = content.search(/^plugins\s*\{/m)
439
+ const end = start >= 0 ? content.indexOf("\n}", start) : -1
440
+ if (end < 0) {
441
+ warn(`couldn't find the plugins { } block in app/build.gradle.kts — add \`id("${id}")\` to it yourself.`)
442
+ continue
443
+ }
444
+ content = content.slice(0, end) + `\n id("${id}") // lecodes:plugin` + content.slice(end)
445
+ log(`${c.green("updated")} android/app/build.gradle.kts: + id("${id}")`)
446
+ }
447
+
448
+ if (content !== before) writeFileSync(path, content)
449
+ }
450
+
451
+ /* ---------------------------------------- build ---------------------------------------- */
452
+
453
+ const gradlew = (androidDir: string, tasks: string[]): string => {
454
+ // `sh gradlew` sidesteps a lost exec bit (zip/copy round-trips drop it).
455
+ if (process.platform !== "win32") return runTool(androidDir, "sh", ["gradlew", ...tasks])
456
+
457
+ // Windows: Node refuses to spawn a .bat directly (EINVAL), and a bare relative name breaks
458
+ // under NoDefaultCurrentDirectoryInExePath — so invoke by ABSOLUTE path through cmd.exe,
459
+ // with verbatim args in the canonical `/s /c ""<bat>" <tasks>"` shape (/s strips exactly
460
+ // the outer quotes, keeping the inner-quoted path intact even with spaces).
461
+ const line = `""${join(androidDir, "gradlew.bat")}" ${tasks.join(" ")}"`
462
+ const r = spawnSync("cmd.exe", ["/d", "/s", "/c", line], {
463
+ cwd: androidDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
464
+ maxBuffer: 128 * 1024 * 1024, windowsVerbatimArguments: true,
465
+ })
466
+ if (r.status !== 0) {
467
+ const tail = ((r.stdout ?? "") + "\n" + (r.stderr ?? "")).split("\n").filter(l => l.trim()).slice(-25).join("\n")
468
+ throw new CliError(`gradlew ${tasks[0] ?? ""} failed:\n${tail}`)
469
+ }
470
+ return r.stdout ?? ""
471
+ }
472
+
473
+ export const buildAndroid = async (args: Args) => {
474
+ const root = projectRoot()
475
+ const config = readJson<AppConfig>(join(root, "app.json"))
476
+ const androidDir = join(root, "android")
477
+ if (!config?.android || !existsSync(androidDir)) {
478
+ throw new CliError("No Android shell here — run `lecodes app init android` first.")
479
+ }
480
+
481
+ if (!flagBool(args, "no-sync")) await syncAndroid(root, config, null)
482
+
483
+ // Gradle needs the Android SDK location: local.properties, or written from the env.
484
+ if (!existsSync(join(androidDir, "local.properties"))) {
485
+ const sdkHome = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
486
+ if (!sdkHome) {
487
+ throw new CliError("Android SDK not found — set ANDROID_HOME, or create android/local.properties with sdk.dir=…")
488
+ }
489
+ writeFileSync(join(androidDir, "local.properties"), `sdk.dir=${sdkHome.replace(/\\/g, "\\\\")}\n`)
490
+ note("Wrote android/local.properties (sdk.dir from the environment).")
491
+ }
492
+
493
+ const configuration = (flagStr(args, "configuration") ?? "Release").toLowerCase()
494
+ const tasks = configuration === "debug"
495
+ ? ["assembleDebug"]
496
+ : flagBool(args, "no-aab") ? ["assembleRelease"] : ["assembleRelease", "bundleRelease"]
497
+
498
+ note(`Building (${tasks.join(" ")})… the first run downloads Gradle + dependencies`)
499
+ gradlew(androidDir, tasks)
500
+
501
+ const outputs: string[] = []
502
+ const apkDir = join(androidDir, "app", "build", "outputs", "apk", configuration)
503
+ const aabDir = join(androidDir, "app", "build", "outputs", "bundle", configuration)
504
+ for (const dir of [apkDir, aabDir]) {
505
+ if (!existsSync(dir)) continue
506
+ for (const f of readdirSync(dir)) {
507
+ if (f.endsWith(".apk") || f.endsWith(".aab")) outputs.push(join(dir, f))
508
+ }
509
+ }
510
+ for (const out of outputs) log(`${c.green("output")} ${out}`)
511
+ if (outputs.some(o => basename(o).includes("unsigned"))) {
512
+ note("The APK is UNSIGNED — create android/keystore.properties (see android/README.md) and rebuild to ship it.")
513
+ }
514
+ success(configuration === "debug"
515
+ ? "Debug build done — `adb install` the APK to test."
516
+ : "Release build done — upload the .aab in the Play Console (the .apk side-loads for testing).")
517
+ }
518
+
519
+ /* ---------------------------------------- open ----------------------------------------- */
520
+
521
+ export const openAndroid = async () => {
522
+ const root = projectRoot()
523
+ const androidDir = join(root, "android")
524
+ if (!existsSync(join(androidDir, "settings.gradle.kts"))) {
525
+ throw new CliError("No Android shell here — run `lecodes app init android` first.")
526
+ }
527
+
528
+ if (process.platform === "darwin") {
529
+ try {
530
+ execFileSync("open", ["-a", "Android Studio", androidDir], { stdio: "ignore" })
531
+ success("Opened android/ in Android Studio.")
532
+ return
533
+ } catch { /* fall through */ }
534
+ } else if (process.platform === "win32") {
535
+ const candidates = [
536
+ join(process.env.LOCALAPPDATA ?? "", "Programs", "Android Studio", "bin", "studio64.exe"),
537
+ "C:\\Program Files\\Android\\Android Studio\\bin\\studio64.exe",
538
+ ]
539
+ for (const exe of candidates) {
540
+ if (!existsSync(exe)) continue
541
+ spawn(exe, [androidDir], { detached: true, stdio: "ignore" }).unref()
542
+ success("Opened android/ in Android Studio.")
543
+ return
544
+ }
545
+ } else {
546
+ try {
547
+ // JetBrains Toolbox / distro launcher script.
548
+ spawn("studio", [androidDir], { detached: true, stdio: "ignore" }).unref()
549
+ success("Opened android/ in Android Studio.")
550
+ return
551
+ } catch { /* fall through */ }
552
+ }
553
+ note("Open the android/ folder in Android Studio.")
554
+ }
555
+
556
+ /* --------------------------------------- doctor ---------------------------------------- */
557
+
558
+ export const doctorAndroidChecks = async (
559
+ root: string, config: AppConfig,
560
+ check: (ok: boolean, label: string, hint?: string, fatal?: boolean) => void,
561
+ ) => {
562
+ const androidDir = join(root, "android")
563
+
564
+ // JDK ≥ 17 (AGP's floor for running Gradle; app code targets JVM 11 regardless).
565
+ const java = spawnSync("java", ["-version"], { encoding: "utf8" })
566
+ const javaOut = `${java.stderr ?? ""}${java.stdout ?? ""}`
567
+ const javaMajor = Number(/version "(\d+)/.exec(javaOut)?.[1] ?? 0)
568
+ check(javaMajor >= 17, `JDK ${javaMajor || "not found"}`, "install JDK 17+ (Android Studio ships one)")
569
+
570
+ const hasSdkHome = !!(process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT) || existsSync(join(androidDir, "local.properties"))
571
+ check(hasSdkHome, "Android SDK location", "set ANDROID_HOME or create android/local.properties")
572
+
573
+ const requestedVariant = config.android?.variant ?? "auto"
574
+ const validVariant = requestedVariant === "auto" || SDK_VARIANTS.includes(requestedVariant as SdkVariant)
575
+ check(validVariant, `android.variant "${requestedVariant}"`, `use "auto" or one of ${SDK_VARIANTS.join(", ")}`)
576
+
577
+ const wrapperJar = join(androidDir, "gradle", "wrapper", "gradle-wrapper.jar")
578
+ check(existsSync(wrapperJar) && statSync(wrapperJar).size > 0, "Gradle wrapper jar",
579
+ `run \`gradle wrapper --gradle-version ${GRADLE_VERSION}\` in android/ (or let Android Studio repair it)`)
580
+
581
+ check(existsSync(join(androidDir, "app", "src", "main", "assets", "app.js")), "Bundled app.js", "run `lecodes app sync`")
582
+
583
+ const linked = shellSdkVersionAndroid(androidDir)
584
+ const pin = config.android?.sdk ?? "latest"
585
+ check(!!linked, `SDK ${linked ?? "version unreadable"}`, "run `lecodes app sync`")
586
+ if (linked && pin !== "latest") {
587
+ check(linked === pin, `SDK matches app.json android.sdk (${pin})`, "run `lecodes app sync`")
588
+ }
589
+ if (linked) {
590
+ try {
591
+ const latest = await fetchLatestSdkVersion(ANDROID_SDK_REPO)
592
+ check(cmpVersion(linked, latest) >= 0, `SDK is the latest release (${latest})`, "run `lecodes app update`", false)
593
+ } catch { /* offline — not a preflight failure */ }
594
+ }
595
+
596
+ const hasKeystore = existsSync(join(androidDir, "keystore.properties")) || !!process.env.LECODES_ANDROID_KEYSTORE_FILE
597
+ check(hasKeystore, "Release signing configured", "android/keystore.properties or LECODES_ANDROID_* env (release builds come out unsigned without it)", false)
598
+
599
+ if (Object.keys(config.plugins ?? {}).includes("push")) {
600
+ check(existsSync(join(androidDir, "app", "google-services.json")), "google-services.json present (push)",
601
+ "download it from the le.codes Firebase project — FCM registration fails without it", false)
602
+ }
603
+ }