lecodes-cli 0.20.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +11052 -7592
  3. package/package.json +14 -8
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap.mat +117 -138
  7. package/runtime/materials/particles-quad.mat +6 -2
  8. package/runtime/materials/terrain-lightmap.mat +123 -0
  9. package/runtime/materials/terrain.mat +36 -55
  10. package/runtime/sdk-types.json +1 -1
  11. package/runtime/web/VERSION.json +5 -0
  12. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  13. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  14. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  15. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  16. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  17. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  18. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  19. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  20. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  21. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  22. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  23. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  24. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  25. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  26. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  27. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  28. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  29. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  30. package/runtime/web/index.html +36 -0
  31. package/runtime/web/player.js +21 -0
  32. package/runtime/web-page/index.html +41 -0
  33. package/runtime/web-page/page.css +1 -0
  34. package/runtime/web-page/page.js +31 -0
  35. package/src/api.ts +1 -302
  36. package/src/cli/args.ts +37 -0
  37. package/src/cli/command.ts +113 -0
  38. package/src/cli/errors.ts +13 -0
  39. package/src/cli/help.ts +158 -0
  40. package/src/cli/index.ts +7 -0
  41. package/src/cli/output.ts +102 -0
  42. package/src/cli/run.ts +106 -0
  43. package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
  44. package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
  45. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
  46. package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
  47. package/src/commands/{app.ts → app/index.ts} +400 -290
  48. package/src/commands/{appShared.ts → app/shared.ts} +43 -44
  49. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
  50. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  51. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
  52. package/src/commands/assets.ts +23 -21
  53. package/src/commands/clone.ts +48 -51
  54. package/src/commands/compile.ts +76 -89
  55. package/src/commands/create.ts +46 -47
  56. package/src/commands/design/comments.ts +361 -0
  57. package/src/commands/design/context.ts +101 -0
  58. package/src/commands/design/index.ts +391 -0
  59. package/src/commands/design/snapshot.ts +134 -0
  60. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  61. package/src/commands/desktop.ts +94 -82
  62. package/src/commands/dev.ts +260 -182
  63. package/src/commands/diff.ts +39 -51
  64. package/src/commands/index.ts +64 -0
  65. package/src/commands/{init.ts → init/index.ts} +195 -191
  66. package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
  67. package/src/commands/install.ts +80 -67
  68. package/src/commands/lightmap.ts +543 -252
  69. package/src/commands/link.ts +87 -94
  70. package/src/commands/login.ts +25 -23
  71. package/src/commands/navmesh.ts +169 -139
  72. package/src/commands/plugin.ts +46 -0
  73. package/src/commands/pn.ts +201 -244
  74. package/src/commands/pull.ts +49 -56
  75. package/src/commands/push.ts +48 -54
  76. package/src/commands/render.ts +176 -111
  77. package/src/commands/scene.ts +63 -55
  78. package/src/commands/serve.ts +58 -0
  79. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  80. package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
  81. package/src/commands/shared.ts +79 -0
  82. package/src/commands/status.ts +22 -26
  83. package/src/commands/test.ts +94 -63
  84. package/src/commands/thumbs.ts +185 -175
  85. package/src/commands/update/index.ts +226 -0
  86. package/src/commands/{types.ts → update/types.ts} +31 -22
  87. package/src/compile/collect.ts +5 -5
  88. package/src/compile/collectLocal.ts +3 -3
  89. package/src/compile/designCompile.ts +4 -4
  90. package/src/compile/headlessBundle.ts +16 -12
  91. package/src/compile/nativeStack.ts +1 -1
  92. package/src/compile/projectCompile.ts +12 -4
  93. package/src/compile/sceneCompile.ts +14 -26
  94. package/src/compile/screenEntry.ts +123 -127
  95. package/src/compile/shaders.ts +3 -3
  96. package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
  97. package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
  98. package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
  99. package/src/dev/androidDev.ts +1 -1
  100. package/src/dev/clientTemplates.ts +18 -14
  101. package/src/dev/devServer.ts +78 -41
  102. package/src/dev/iosDev.ts +180 -0
  103. package/src/dev/webRunner.ts +83 -15
  104. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
  105. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
  106. package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
  107. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  108. package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
  109. package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
  110. package/src/{peers.ts → hosts/peers.ts} +2 -2
  111. package/src/{releases.ts → hosts/releases.ts} +1 -1
  112. package/src/index.ts +35 -480
  113. package/src/platform/api.ts +302 -0
  114. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  115. package/src/{config.ts → platform/config.ts} +1 -1
  116. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  117. package/src/plugin/contract.ts +438 -0
  118. package/src/plugin/emitKotlin.ts +357 -0
  119. package/src/plugin/emitSdk.ts +259 -0
  120. package/src/plugin/emitSwift.ts +336 -0
  121. package/src/plugin/emitWeb.ts +383 -0
  122. package/src/plugin/generate.ts +157 -0
  123. package/src/plugin/lines.ts +28 -0
  124. package/src/plugin/lower.ts +231 -0
  125. package/src/plugin/manifest.ts +192 -0
  126. package/src/{projectEnv.ts → project/env.ts} +33 -2
  127. package/src/{ignore.ts → project/ignore.ts} +164 -163
  128. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  129. package/src/{manifest.ts → project/manifest.ts} +1 -1
  130. package/src/{project.ts → project/materialize.ts} +2 -2
  131. package/src/project/paths.ts +20 -0
  132. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  133. package/src/{types.ts → project/types.ts} +0 -0
  134. package/runtime/materials/lightmap-baked-lite.mat +0 -175
  135. package/runtime/materials/lightmap-baked.mat +0 -176
  136. package/runtime/scene-harness.json +0 -1
  137. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  138. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  139. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  140. package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
  141. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  142. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  143. package/runtime/web/assets/index-BMt7AnC5.js +0 -2
  144. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  145. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  146. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  147. package/runtime/web/embed.html +0 -32
  148. package/runtime/web/embed.js +0 -171
  149. package/src/commands/design.ts +0 -846
  150. package/src/commands/update.ts +0 -211
  151. package/src/util.ts +0 -146
  152. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  153. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  154. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
@@ -1,32 +1,35 @@
1
1
  import { execFileSync, spawn, spawnSync } from "node:child_process"
2
- import { createHash } from "node:crypto"
2
+ import { createHash, randomBytes } from "node:crypto"
3
3
  import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
4
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"
5
+ import { basename, dirname, join, relative, resolve } from "node:path"
6
+ import { readManifest } from "../../project/manifest"
7
+ import { compileShell, type BundleResource } from "../compile"
8
+ import { CliError, UsageError, bool, c, defineCommand, log, note, promptHidden, str, success, warn, type FlagValues } from "../../cli"
9
+ import { upsertDotEnv } from "../../project/env"
9
10
  import {
10
- type AppConfig, SDK_VARIANTS, VERSION_RE, cmpVersion, ensureShellIgnored, fetchLatestSdkVersion,
11
+ type AppConfig, SDK_VARIANTS, VERSION_RE, childEnv, cmpVersion, ensureShellIgnored, fetchLatestSdkVersion,
11
12
  projectRoot, readJson, resolvePlugin, resolveSdkVersion, resolveVariant, runTool, slugify,
12
13
  writeJson, type LockEntry,
13
- } from "./appShared"
14
- import { loadIconMaster, restampManifestIcon, writeAndroidIcons } from "./appIcon"
14
+ } from "./shared"
15
+ import { loadIconMaster, restampManifestIcon, writeAndroidIcons } from "./icon"
15
16
  import {
16
- ANDROID_GITIGNORE, ANDROID_README, COLORS_XML, GRADLE_PROPERTIES, GRADLE_VERSION,
17
+ ANDROID_GITIGNORE, ANDROID_README, APP_PROGUARD_RULES, COLORS_XML, GRADLE_PROPERTIES, GRADLE_VERSION,
17
18
  GRADLE_PLUGIN_VERSIONS, IC_LAUNCHER_XML, aarWrapperBuildGradle, appBuildGradleTemplate,
18
19
  appManifestTemplate, appViewModelTemplate, gradleWrapperProperties, mainActivityTemplate,
19
20
  pluginBuildGradleTemplate, rootBuildGradleTemplate, runtimeBuildGradleTemplate,
20
21
  runtimeKotlinTemplate, settingsGradleTemplate, stringsXmlTemplate, themesXmlTemplate,
21
22
  type AndroidRuntimePlugin, type AndroidShellIdentity,
22
- } from "./appTemplatesAndroid"
23
- import { GRADLEW_BAT, GRADLEW_SH } from "./appTemplatesGradlew"
24
- import { type SdkVariant } from "./appTemplates"
23
+ } from "./templates/android"
24
+ import { GRADLEW_BAT, GRADLEW_SH } from "./templates/gradlew"
25
+ import { type SdkVariant } from "./templates/ios"
25
26
 
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. */
27
+ /* The Android arm of `lecodes app` — the mirror of the iOS flows in index.ts, over Gradle
28
+ * instead of Xcode: init / sync / open / doctor checks as functions the shared command nodes call,
29
+ * plus the two Android-only nodes defined here — `app build android` and `app keystore …`.
30
+ * Same ownership contract (see templates/android.ts); the SDK is the lecodes-android fat AAR
31
+ * published as GitHub Release assets on the dist repo below and resolved by Gradle through an
32
+ * ivy repository over the release-download URL pattern. */
30
33
 
31
34
  export const ANDROID_SDK_REPO = "letary/lecodes-android-sdk"
32
35
 
@@ -93,7 +96,10 @@ const rmrf = (path: string) => rmSync(path, { recursive: true, force: true, maxR
93
96
 
94
97
  /* ---------------------------------------- init ---------------------------------------- */
95
98
 
96
- export const initAndroid = async (args: Args) => {
99
+ /** The `app init android` flags (index.ts declares the shared init node). */
100
+ export interface AndroidInitOptions { name?: string, appId?: string, variant?: string, sdk?: string }
101
+
102
+ export const initAndroid = async (opts: AndroidInitOptions) => {
97
103
  const root = projectRoot()
98
104
  const androidDir = join(root, "android")
99
105
  if (existsSync(androidDir)) {
@@ -102,7 +108,7 @@ export const initAndroid = async (args: Args) => {
102
108
 
103
109
  const existing = readJson<AppConfig>(join(root, "app.json")) ?? {}
104
110
 
105
- const variant = flagStr(args, "variant") ?? existing.android?.variant ?? "auto"
111
+ const variant = opts.variant ?? existing.android?.variant ?? "auto"
106
112
  if (variant !== "auto" && !SDK_VARIANTS.includes(variant as SdkVariant)) {
107
113
  throw new CliError(`--variant must be auto, ${SDK_VARIANTS.join(", ")} (got "${variant}")`)
108
114
  }
@@ -110,11 +116,11 @@ export const initAndroid = async (args: Args) => {
110
116
  // The native SDK: a new shell starts on the newest published release, pinned into app.json so
111
117
  // builds stay reproducible (`lecodes app update` moves the pin). `--sdk latest` opts into
112
118
  // re-resolving on every sync instead.
113
- const sdkFlag = flagStr(args, "sdk") ?? existing.android?.sdk
119
+ const sdkFlag = opts.sdk ?? existing.android?.sdk
114
120
  const sdkVersion = await resolveSdkVersion(process.env.LECODES_ANDROID_SDK ?? sdkFlag ?? "latest", null, ANDROID_SDK_OPTS)
115
121
  const sdk = sdkFlag === "latest" ? "latest" : sdkVersion
116
122
 
117
- const name = flagStr(args, "name") ?? existing.name ?? basename(root)
123
+ const name = opts.name ?? existing.name ?? basename(root)
118
124
  // Dashes are legal in a slug but not in a Java package — strip them for the default id.
119
125
  const defaultAppId = `com.example.${slugify(name).replace(/-/g, "")}`
120
126
  const config: AppConfig = {
@@ -123,11 +129,12 @@ export const initAndroid = async (args: Args) => {
123
129
  version: existing.version ?? "1.0.0",
124
130
  plugins: existing.plugins ?? {},
125
131
  android: {
126
- applicationId: flagStr(args, "app-id") ?? existing.android?.applicationId ?? defaultAppId,
132
+ applicationId: opts.appId ?? existing.android?.applicationId ?? defaultAppId,
127
133
  versionCode: existing.android?.versionCode ?? 1,
128
134
  sdk,
129
135
  variant,
130
136
  ...(existing.android?.signing ? { signing: existing.android.signing } : {}),
137
+ ...(existing.android?.minify === false ? { minify: false } : {}),
131
138
  },
132
139
  }
133
140
  writeJson(join(root, "app.json"), config)
@@ -164,7 +171,8 @@ export const initAndroid = async (args: Args) => {
164
171
  const wrapperJar = await ensureGradleWrapperJar()
165
172
  if (wrapperJar) write("gradle/wrapper/gradle-wrapper.jar", wrapperJar)
166
173
 
167
- write("app/build.gradle.kts", appBuildGradleTemplate(identity))
174
+ write("app/build.gradle.kts", appBuildGradleTemplate(identity, config.android!.minify ?? true))
175
+ write("app/proguard-rules.pro", APP_PROGUARD_RULES)
168
176
  write("app/src/main/AndroidManifest.xml", appManifestTemplate())
169
177
  write(`app/src/main/java/${pkgPath}/MainActivity.kt`, mainActivityTemplate(identity.applicationId))
170
178
  write(`app/src/main/java/${pkgPath}/AppViewModel.kt`, appViewModelTemplate(identity.applicationId))
@@ -233,7 +241,7 @@ export const syncAndroid = async (root: string, config: AppConfig, prior: SyncAr
233
241
  bundleJs = prior.bundleJs
234
242
  resources = prior.resources
235
243
  } else {
236
- resources = await compileShell({ _: [], flags: { o: join(assetsDir, "app.js") } }, { remotePatterns })
244
+ resources = await compileShell(join(assetsDir, "app.js"), { remotePatterns })
237
245
  bundleJs = readFileSync(join(assetsDir, "app.js"), "utf8")
238
246
  }
239
247
 
@@ -326,7 +334,9 @@ export const syncAndroid = async (root: string, config: AppConfig, prior: SyncAr
326
334
  // bundle to download: engine assets ride inside the variant AAR itself (GL-less variants
327
335
  // simply ship without the Filament assets).
328
336
  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
337
+ // engine-synthesized, not project files: the ubershader archive, the capture flag and the
338
+ // engine's own materials (`material:<name>` — they ride the SDK)
339
+ const IMPLICIT_PRELOADS = { has: (name: string) => name === "uberarchive" || name === "capture" || name.startsWith("material:") }
330
340
  const byName = new Map<string, BundleResource[]>()
331
341
  for (const r of resources) {
332
342
  if (r.remote || !r.absPath) continue
@@ -416,6 +426,16 @@ const restampAppBuildGradle = (androidDir: string, config: AppConfig, gradlePlug
416
426
  warn(`couldn't restamp versionCode in app/build.gradle.kts — set it to ${versionCode} yourself.`)
417
427
  }
418
428
 
429
+ // R8 on/off (app.json android.minify, default on) — the two values travel together: AGP
430
+ // refuses isShrinkResources without isMinifyEnabled.
431
+ const minify = config.android?.minify ?? true
432
+ const r8Keys = ["isMinifyEnabled", "isShrinkResources"]
433
+ if (r8Keys.every(k => new RegExp(`${k} = (true|false)`).test(content))) {
434
+ for (const k of r8Keys) content = content.replace(new RegExp(`${k} = (true|false)`), `${k} = ${minify}`)
435
+ } else {
436
+ warn(`couldn't restamp ${r8Keys.join("/")} in app/build.gradle.kts (a shell generated before R8 was on by default) — see android/README.md "Building" for the release block to add.`)
437
+ }
438
+
419
439
  // Which requested Gradle plugins actually get applied: google-services only once the app has
420
440
  // its google-services.json (the plugin hard-fails the build without it).
421
441
  const applied = new Set<string>()
@@ -461,7 +481,7 @@ const gradlew = (androidDir: string, tasks: string[]): string => {
461
481
  const line = `""${join(androidDir, "gradlew.bat")}" ${tasks.join(" ")}"`
462
482
  const r = spawnSync("cmd.exe", ["/d", "/s", "/c", line], {
463
483
  cwd: androidDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
464
- maxBuffer: 128 * 1024 * 1024, windowsVerbatimArguments: true,
484
+ maxBuffer: 128 * 1024 * 1024, windowsVerbatimArguments: true, env: childEnv(),
465
485
  })
466
486
  if (r.status !== 0) {
467
487
  const tail = ((r.stdout ?? "") + "\n" + (r.stderr ?? "")).split("\n").filter(l => l.trim()).slice(-25).join("\n")
@@ -470,7 +490,19 @@ const gradlew = (androidDir: string, tasks: string[]): string => {
470
490
  return r.stdout ?? ""
471
491
  }
472
492
 
473
- export const buildAndroid = async (args: Args) => {
493
+ const androidBuildFlags = {
494
+ configuration: str("Release (default) or Debug", { value: "<c>", default: "Release" }),
495
+ apk: bool("only the .apk"),
496
+ aab: bool("only the .aab"),
497
+ "no-aab": bool("the old spelling of --apk"),
498
+ "allow-unsigned": bool("build a release without a signing key anyway"),
499
+ out: str("output dir (default dist/)", { alias: "o", value: "<dir>" }),
500
+ slug: str("file name stem (default: the app name)", { value: "<name>" }),
501
+ "no-sync": bool("skip the `lecodes app sync` step"),
502
+ }
503
+
504
+ /** `lecodes app build android` — sync, Gradle, then the artifacts copied under dist/. */
505
+ const buildAndroid = async (flags: FlagValues<typeof androidBuildFlags>): Promise<void> => {
474
506
  const root = projectRoot()
475
507
  const config = readJson<AppConfig>(join(root, "app.json"))
476
508
  const androidDir = join(root, "android")
@@ -478,7 +510,43 @@ export const buildAndroid = async (args: Args) => {
478
510
  throw new CliError("No Android shell here — run `lecodes app init android` first.")
479
511
  }
480
512
 
481
- if (!flagBool(args, "no-sync")) await syncAndroid(root, config, null)
513
+ const configuration = flags.configuration.toLowerCase()
514
+ if (configuration !== "release" && configuration !== "debug") {
515
+ throw new CliError(`--configuration must be Release or Debug (got "${configuration}")`)
516
+ }
517
+ // Artifacts: both by default; --apk / --aab pick one (both flags = both); --no-aab = the old
518
+ // spelling of --apk.
519
+ const apkFlag = flags.apk || flags["no-aab"]
520
+ const aabFlag = flags.aab && !flags["no-aab"]
521
+ const wantApk = apkFlag || !aabFlag
522
+ const wantAab = aabFlag || (!apkFlag && !flags["no-aab"])
523
+
524
+ // Signing is resolved HERE, before Gradle runs: a release that would come out unsigned is
525
+ // refused with the ways to fix it, instead of Gradle producing an app-release-unsigned.apk
526
+ // (and an .aab with the same name as a signed one) that can't be installed or uploaded.
527
+ let signing: AndroidSigning | null = null
528
+ if (configuration === "release") {
529
+ signing = resolveAndroidSigning(androidDir)
530
+ if (!signing) {
531
+ if (!flags["allow-unsigned"]) {
532
+ throw new CliError([
533
+ "No release signing key — the .apk/.aab would be unsigned (uninstallable, not uploadable).",
534
+ " lecodes app keystore create make one (path + passwords go to the project .env)",
535
+ " lecodes app keystore use <file> attach an existing keystore",
536
+ " --allow-unsigned build anyway (throwaway builds only)",
537
+ ].join("\n"))
538
+ }
539
+ warn("Building UNSIGNED (--allow-unsigned) — the output can't be installed or uploaded.")
540
+ } else if (!existsSync(signing.storeFile)) {
541
+ throw new CliError(`Keystore ${signing.storeFile} (from ${signing.source}) does not exist.`)
542
+ } else {
543
+ const missing = (["storePassword", "keyAlias", "keyPassword"] as const).filter(k => !signing![k])
544
+ if (missing.length > 0) throw new CliError(`Signing from ${signing.source} is incomplete — missing ${missing.join(", ")} (see android/README.md "Release signing").`)
545
+ log(`${c.green("signing")} ${signing.storeFile} alias ${signing.keyAlias}${c.dim(` (${signing.source})`)}`)
546
+ }
547
+ }
548
+
549
+ if (!flags["no-sync"]) await syncAndroid(root, config, null)
482
550
 
483
551
  // Gradle needs the Android SDK location: local.properties, or written from the env.
484
552
  if (!existsSync(join(androidDir, "local.properties"))) {
@@ -490,32 +558,362 @@ export const buildAndroid = async (args: Args) => {
490
558
  note("Wrote android/local.properties (sdk.dir from the environment).")
491
559
  }
492
560
 
493
- const configuration = (flagStr(args, "configuration") ?? "Release").toLowerCase()
494
- const tasks = configuration === "debug"
495
- ? ["assembleDebug"]
496
- : flagBool(args, "no-aab") ? ["assembleRelease"] : ["assembleRelease", "bundleRelease"]
497
-
561
+ const cap = configuration === "release" ? "Release" : "Debug"
562
+ const tasks = [...(wantApk ? [`assemble${cap}`] : []), ...(wantAab ? [`bundle${cap}`] : [])]
498
563
  note(`Building (${tasks.join(" ")})… the first run downloads Gradle + dependencies`)
499
564
  gradlew(androidDir, tasks)
500
565
 
501
- const outputs: string[] = []
566
+ // Gradle's outputs, then a copy under dist/ named by the app (the desktop build's convention)
567
+ // — the shell's build/ tree stays where Android Studio expects it.
568
+ const produced: string[] = []
502
569
  const apkDir = join(androidDir, "app", "build", "outputs", "apk", configuration)
503
570
  const aabDir = join(androidDir, "app", "build", "outputs", "bundle", configuration)
504
- for (const dir of [apkDir, aabDir]) {
505
- if (!existsSync(dir)) continue
571
+ for (const [dir, want] of [[apkDir, wantApk], [aabDir, wantAab]] as const) {
572
+ if (!want || !existsSync(dir)) continue
506
573
  for (const f of readdirSync(dir)) {
507
- if (f.endsWith(".apk") || f.endsWith(".aab")) outputs.push(join(dir, f))
574
+ if (f.endsWith(".apk") || f.endsWith(".aab")) produced.push(join(dir, f))
508
575
  }
509
576
  }
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.")
577
+ if (produced.length === 0) throw new CliError("Gradle finished but produced no .apk/.aab — check android/app/build/outputs.")
578
+
579
+ const slug = flags.slug ?? slugify(config.name ?? basename(root))
580
+ const outDir = resolve(root, flags.out ?? join(root, "dist"))
581
+ mkdirSync(outDir, { recursive: true })
582
+ const stem = `${slug}-${config.version ?? "1.0.0"}-${config.android.versionCode ?? 1}${configuration === "debug" ? "-debug" : ""}`
583
+ for (const src of produced) {
584
+ const unsigned = configuration === "release" && !signing
585
+ const dest = join(outDir, `${stem}${unsigned ? "-unsigned" : ""}${src.endsWith(".aab") ? ".aab" : ".apk"}`)
586
+ cpSync(src, dest)
587
+ const size = (statSync(dest).size / (1024 * 1024)).toFixed(1)
588
+ log(`${c.green("output")} ${relative(root, dest) || dest}${c.dim(` (${size} MB)`)}`)
513
589
  }
514
590
  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).")
591
+ ? "Debug build done — `adb install` the .apk to test (or `lecodes dev --android --apk <file>`)."
592
+ : signing
593
+ ? "Release build done — upload the .aab in the Play Console (the .apk side-loads for testing)."
594
+ : "Unsigned release build done — sign it before it goes anywhere.")
595
+ }
596
+
597
+ export const androidBuild = defineCommand({
598
+ name: "android",
599
+ summary: "Sync, gradlew assembleRelease + bundleRelease, copy the results to dist/<name>-<version>-<versionCode>.apk|.aab",
600
+ description: "Signing comes from the project .env keys `lecodes app keystore` writes (LECODES_ANDROID_KEYSTORE_FILE / _KEYSTORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD) or android/keystore.properties; a release build without a key is refused — --allow-unsigned builds one anyway (throwaway builds only). Release builds run R8 (app.json android.minify: false turns it off). Gradle needs the Android SDK location: android/local.properties, or written from ANDROID_HOME on the first build.\n\nBoth artifacts are built by default; --apk / --aab pick one. The shell's own build/ tree stays where Android Studio expects it — dist/ gets a copy named by the app.",
601
+ flags: androidBuildFlags,
602
+ examples: ["lecodes app build android", "lecodes app build android --configuration Debug --apk", "lecodes app build android -o out/"],
603
+ run: ({ flags }) => buildAndroid(flags),
604
+ })
605
+
606
+ /* --------------------------------------- signing --------------------------------------- */
607
+
608
+ /** The four names the generated app/build.gradle.kts reads with System.getenv — the project
609
+ * .env is where they normally live (projectEnv.ts loads it; never pushed). */
610
+ export const ANDROID_SIGNING_ENV = {
611
+ storeFile: "LECODES_ANDROID_KEYSTORE_FILE",
612
+ storePassword: "LECODES_ANDROID_KEYSTORE_PASSWORD",
613
+ keyAlias: "LECODES_ANDROID_KEY_ALIAS",
614
+ keyPassword: "LECODES_ANDROID_KEY_PASSWORD",
615
+ } as const
616
+
617
+ export interface AndroidSigning {
618
+ /** Where `storeFile` came from — per key the file wins, exactly like the Gradle side. */
619
+ source: "keystore.properties" | "env"
620
+ /** Absolute. */
621
+ storeFile: string
622
+ storePassword?: string
623
+ keyAlias?: string
624
+ keyPassword?: string
625
+ }
626
+
627
+ /** The subset of java.util.Properties a keystore.properties uses: `key=value` / `key: value`,
628
+ * `#` / `!` comments, `\\` `\:` `\=` escapes. */
629
+ const readProperties = (path: string): Record<string, string> => {
630
+ const out: Record<string, string> = {}
631
+ for (const raw of readFileSync(path, "utf8").split(/\r?\n/)) {
632
+ const line = raw.trim()
633
+ if (!line || line.startsWith("#") || line.startsWith("!")) continue
634
+ const m = /^((?:\\.|[^=:\s])+)\s*[=:]?\s*(.*)$/.exec(line)
635
+ if (!m) continue
636
+ const unescape = (v: string) => v.replace(/\\(.)/g, "$1")
637
+ out[unescape(m[1])] = unescape(m[2])
638
+ }
639
+ return out
640
+ }
641
+
642
+ /** The signing chain exactly as the generated app/build.gradle.kts resolves it: per key,
643
+ * android/keystore.properties first, then the LECODES_ANDROID_* environment (the project .env
644
+ * has been loaded into it). A relative storeFile resolves against android/ like
645
+ * `rootProject.file()` does; `./` values from .env arrive absolute already. Null = no key. */
646
+ export const resolveAndroidSigning = (androidDir: string): AndroidSigning | null => {
647
+ const propsPath = join(androidDir, "keystore.properties")
648
+ const props = existsSync(propsPath) ? readProperties(propsPath) : {}
649
+ const pick = (key: keyof typeof ANDROID_SIGNING_ENV) => props[key] ?? process.env[ANDROID_SIGNING_ENV[key]]
650
+ const storeFile = pick("storeFile")
651
+ if (!storeFile) return null
652
+ return {
653
+ source: props.storeFile ? "keystore.properties" : "env",
654
+ storeFile: resolve(androidDir, storeFile),
655
+ storePassword: pick("storePassword"),
656
+ keyAlias: pick("keyAlias"),
657
+ keyPassword: pick("keyPassword"),
658
+ }
659
+ }
660
+
661
+ /** keytool: JAVA_HOME → PATH → the JBR inside Android Studio (the only JDK on many machines). */
662
+ const findKeytool = (): string | null => {
663
+ const exe = process.platform === "win32" ? "keytool.exe" : "keytool"
664
+ const fromJavaHome = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, "bin", exe) : null
665
+ if (fromJavaHome && existsSync(fromJavaHome)) return fromJavaHome
666
+ const onPath = spawnSync("keytool", ["-help"], { encoding: "utf8", env: childEnv() })
667
+ if (!onPath.error) return "keytool"
668
+ const studio = process.platform === "win32"
669
+ ? [join(process.env.LOCALAPPDATA ?? "", "Programs", "Android Studio", "jbr", "bin", exe), "C:\\Program Files\\Android\\Android Studio\\jbr\\bin\\keytool.exe"]
670
+ : process.platform === "darwin"
671
+ ? ["/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/keytool"]
672
+ : [join(homedir(), "android-studio", "jbr", "bin", "keytool"), "/opt/android-studio/jbr/bin/keytool", "/usr/local/android-studio/jbr/bin/keytool"]
673
+ return studio.find(p => existsSync(p)) ?? null
674
+ }
675
+
676
+ /** Run keytool with English output (the alias listing is parsed) and passwords passed through
677
+ * the environment (`-storepass:env`), never on the command line. */
678
+ const keytool = (args: string[], secrets: Record<string, string> = {}): string => {
679
+ const exe = findKeytool()
680
+ if (!exe) throw new CliError("keytool not found — install a JDK (Android Studio ships one) or point JAVA_HOME at it.")
681
+ const r = spawnSync(exe, ["-J-Duser.language=en", ...args], { encoding: "utf8", env: childEnv(secrets), stdio: ["ignore", "pipe", "pipe"] })
682
+ if (r.status !== 0) {
683
+ const tail = ((r.stdout ?? "") + "\n" + (r.stderr ?? "")).split("\n").filter(l => l.trim()).slice(-6).join("\n")
684
+ throw new CliError(`keytool ${args[0]} failed:\n${tail}`)
685
+ }
686
+ return (r.stdout ?? "") + (r.stderr ?? "")
687
+ }
688
+
689
+ const KEYTOOL_PASS = "LECODES_KEYTOOL_PASS"
690
+
691
+ /** PrivateKeyEntry aliases from `keytool -list` (English): "<alias>, <date>, PrivateKeyEntry,". */
692
+ const listPrivateKeyAliases = (listing: string): string[] =>
693
+ listing.split(/\r?\n/).map(l => /^(.+?), .*?, PrivateKeyEntry,/.exec(l)?.[1]).filter((a): a is string => !!a)
694
+
695
+ /** How a keystore path is spelled in .env: inside the project → `./…` (resolved against the
696
+ * .env's own dir by projectEnv, so it survives `lecodes` running from a subdirectory); under
697
+ * the home dir → `~/…`; elsewhere absolute. Forward slashes throughout. */
698
+ const envPathFor = (root: string, abs: string): string => {
699
+ const fwd = (p: string) => p.replace(/\\/g, "/")
700
+ const rel = relative(root, abs)
701
+ if (rel && !rel.startsWith("..") && !rel.includes(":")) return `./${fwd(rel)}`
702
+ const home = relative(homedir(), abs)
703
+ if (home && !home.startsWith("..") && !home.includes(":")) return `~/${fwd(home)}`
704
+ return fwd(abs)
705
+ }
706
+
707
+ /** A .env value: raw when plain, double-quoted (with `"` / `\` escaped) when it carries whitespace,
708
+ * `#` or quotes — the subset parseDotEnv unescapes. */
709
+ const envValue = (v: string): string => /[\s#"'\\]/.test(v) ? `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : v
710
+
711
+ /** The store password from --password, --generate (create only), or a hidden prompt. */
712
+ const resolveKeystorePassword = async (opts: { password?: string, generate?: boolean }, create: boolean): Promise<string> => {
713
+ if (opts.password) {
714
+ if (create && opts.password.length < 6) throw new CliError("keytool needs a password of at least 6 characters.")
715
+ return opts.password
716
+ }
717
+ if (create && opts.generate) return randomBytes(24).toString("base64url")
718
+ if (!process.stdin.isTTY) {
719
+ throw new CliError(`No terminal to ask for the keystore password — pass --password <pw>${create ? " (or --generate)" : ""}.`)
720
+ }
721
+ const pw = await promptHidden(create ? "Keystore password (min 6 chars)" : "Keystore password")
722
+ if (create) {
723
+ if (pw.length < 6) throw new CliError("keytool needs a password of at least 6 characters.")
724
+ const again = await promptHidden("Repeat password")
725
+ if (again !== pw) throw new CliError("Passwords don't match.")
726
+ }
727
+ return pw
517
728
  }
518
729
 
730
+ /** Bank the key in .env (+ .gitignore) and its metadata in app.json android.signing. */
731
+ const writeSigningConfig = (root: string, config: AppConfig, storeFile: string, alias: string, storePassword: string, keyPassword: string) => {
732
+ const envPath = envPathFor(root, storeFile)
733
+ const touched = upsertDotEnv(root, {
734
+ [ANDROID_SIGNING_ENV.storeFile]: envPath,
735
+ [ANDROID_SIGNING_ENV.storePassword]: envValue(storePassword),
736
+ [ANDROID_SIGNING_ENV.keyAlias]: alias,
737
+ [ANDROID_SIGNING_ENV.keyPassword]: envValue(keyPassword),
738
+ })
739
+ for (const t of touched) log(`${c.green(t === ".env" ? "written" : "updated")} ${t}${t === ".env" ? c.dim(" (LECODES_ANDROID_KEYSTORE_FILE / _KEYSTORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD)") : ""}`)
740
+
741
+ // A keystore inside the project but outside android/ (which has its own .gitignore) must not
742
+ // end up in git either.
743
+ const rel = relative(root, storeFile)
744
+ if (rel && !rel.startsWith("..") && !rel.includes(":") && !rel.replace(/\\/g, "/").startsWith("android/")) {
745
+ const ignorePath = join(root, ".gitignore")
746
+ const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : ""
747
+ if (!ignore.split("\n").some(l => l.trim() === "*.keystore")) {
748
+ writeFileSync(ignorePath, ignore + (!ignore || ignore.endsWith("\n") ? "" : "\n") + "*.keystore\n")
749
+ log(`${c.green("updated")} .gitignore (+ *.keystore)`)
750
+ }
751
+ }
752
+
753
+ const configPath = join(root, "app.json")
754
+ const fresh = readJson<AppConfig>(configPath) ?? config
755
+ fresh.android = { ...(fresh.android ?? {}), signing: { storeFile: envPath, keyAlias: alias } }
756
+ writeJson(configPath, fresh)
757
+ log(`${c.green("written")} app.json (android.signing — metadata only, no passwords)`)
758
+ }
759
+
760
+ /** Fingerprints + validity for one alias (`keytool -list -v`, English). */
761
+ const printCertificate = (storeFile: string, alias: string, storePassword: string) => {
762
+ const out = keytool(["-list", "-v", "-keystore", storeFile, "-alias", alias, "-storepass:env", KEYTOOL_PASS], { [KEYTOOL_PASS]: storePassword })
763
+ for (const line of out.split(/\r?\n/)) {
764
+ const t = line.trim()
765
+ if (/^(Alias name|Owner|Valid from|SHA1|SHA256):/.test(t)) log(` ${t}`)
766
+ }
767
+ }
768
+
769
+ const PLAY_APP_SIGNING_NOTE = "With Play App Signing (default for new Play apps) this is the UPLOAD key — Google keeps the app signing key, and a lost upload key can be reset through Play support. Without it, losing this file means the app can never be updated. Back it up either way."
770
+
771
+ /** Every keystore subcommand starts from the project + its app.json (which may not exist yet). */
772
+ const keystoreProject = (): { root: string, config: AppConfig } => {
773
+ const root = projectRoot()
774
+ return { root, config: readJson<AppConfig>(join(root, "app.json")) ?? {} }
775
+ }
776
+
777
+ const passwordFlag = { password: str("keystore password (else asked on the terminal)", { value: "<pw>" }) }
778
+
779
+ const keystoreCreateFlags = {
780
+ alias: str("key alias", { value: "<a>", default: "upload" }),
781
+ out: str("keystore path (default android/release.keystore)", { alias: "o", value: "<file>" }),
782
+ ...passwordFlag,
783
+ generate: bool("a random password instead of asking"),
784
+ validity: str("certificate validity in days", { value: "<days>", default: "10000" }),
785
+ dname: str("X.500 distinguished name (default CN=<app name>)", { value: "<X.500>" }),
786
+ force: bool("overwrite an existing keystore / signing config"),
787
+ }
788
+
789
+ /** `lecodes app keystore create` — a new upload key, banked in .env. */
790
+ const keystoreCreate = async (flags: FlagValues<typeof keystoreCreateFlags>): Promise<void> => {
791
+ const { root, config } = keystoreProject()
792
+ const alias = flags.alias
793
+ const storeFile = resolve(root, flags.out ?? join("android", "release.keystore"))
794
+ const validity = flags.validity
795
+ const dname = flags.dname ?? `CN=${(config.name ?? basename(root)).replace(/[,=+<>#;"\\]/g, " ").trim() || "app"}`
796
+ const force = flags.force
797
+
798
+ if (existsSync(storeFile) && !force) {
799
+ throw new CliError(`${relative(root, storeFile)} already exists — a signing key is not something to overwrite. \`lecodes app keystore use ${relative(root, storeFile)}\` attaches it; --force generates a new one over it.`)
800
+ }
801
+ const current = resolveAndroidSigning(join(root, "android"))
802
+ if (current && !force) {
803
+ throw new CliError(`Release signing is already configured (${current.storeFile}, from ${current.source}) — --force writes the new key over the .env lines (the old file stays).`)
804
+ }
805
+
806
+ const password = await resolveKeystorePassword(flags, true)
807
+ mkdirSync(dirname(storeFile), { recursive: true })
808
+ keytool([
809
+ "-genkeypair", "-keystore", storeFile, "-alias", alias, "-keyalg", "RSA", "-keysize", "2048",
810
+ "-validity", validity, "-dname", dname, "-storepass:env", KEYTOOL_PASS, "-keypass:env", KEYTOOL_PASS,
811
+ ], { [KEYTOOL_PASS]: password })
812
+ log(`${c.green("created")} ${relative(root, storeFile)} alias ${alias}, ${dname}, valid ${validity} days`)
813
+
814
+ writeSigningConfig(root, config, storeFile, alias, password, password)
815
+ printCertificate(storeFile, alias, password)
816
+ log("")
817
+ success("Release signing key ready — `lecodes app build android` signs with it.")
818
+ note(PLAY_APP_SIGNING_NOTE)
819
+ }
820
+
821
+ const keystoreUseFlags = {
822
+ alias: str("key alias (needed when the keystore holds several keys)", { value: "<a>" }),
823
+ ...passwordFlag,
824
+ "key-password": str("the key's own password when it differs from the store's (JKS)", { value: "<pw>" }),
825
+ copy: bool("copy the file into android/ and point the config at the copy"),
826
+ force: bool("(--copy) replace a same-named file in android/"),
827
+ }
828
+
829
+ /** `lecodes app keystore use <file>` — attach an existing keystore. */
830
+ const keystoreUse = async (given: string | undefined, flags: FlagValues<typeof keystoreUseFlags>): Promise<void> => {
831
+ const { root, config } = keystoreProject()
832
+ if (!given) throw new UsageError("Which keystore? `lecodes app keystore use <file>`")
833
+ let storeFile = resolve(root, given.startsWith("~/") ? join(homedir(), given.slice(2)) : given)
834
+ if (!existsSync(storeFile)) throw new CliError(`Keystore not found: ${storeFile}`)
835
+
836
+ const password = await resolveKeystorePassword(flags, false)
837
+ const aliases = listPrivateKeyAliases(keytool(["-list", "-keystore", storeFile, "-storepass:env", KEYTOOL_PASS], { [KEYTOOL_PASS]: password }))
838
+ if (aliases.length === 0) throw new CliError("That keystore holds no private key entry — nothing to sign with.")
839
+ let alias = flags.alias
840
+ if (!alias) {
841
+ if (aliases.length > 1) throw new CliError(`Several keys in the keystore — pick one with --alias: ${aliases.join(", ")}`)
842
+ alias = aliases[0]
843
+ } else if (!aliases.includes(alias)) {
844
+ throw new CliError(`No key "${alias}" in the keystore — it has: ${aliases.join(", ")}`)
845
+ }
846
+ // PKCS12 stores use one password for store and key; a JKS key may have its own.
847
+ const keyPassword = flags["key-password"] ?? password
848
+
849
+ if (flags.copy) {
850
+ const dest = join(root, "android", basename(storeFile))
851
+ if (resolve(dest) !== storeFile) {
852
+ if (existsSync(dest) && !flags.force) throw new CliError(`${relative(root, dest)} already exists — --force to replace it.`)
853
+ mkdirSync(dirname(dest), { recursive: true })
854
+ cpSync(storeFile, dest)
855
+ log(`${c.green("copied")} ${relative(root, dest)}`)
856
+ storeFile = dest
857
+ }
858
+ }
859
+
860
+ writeSigningConfig(root, config, storeFile, alias, password, keyPassword)
861
+ printCertificate(storeFile, alias, password)
862
+ log("")
863
+ success(`Release signing uses ${relative(root, storeFile) || storeFile} (alias ${alias}).`)
864
+ note(PLAY_APP_SIGNING_NOTE)
865
+ }
866
+
867
+ /** `lecodes app keystore info` — what the build would sign with, and its fingerprints. */
868
+ const keystoreInfo = async (flags: FlagValues<typeof passwordFlag>): Promise<void> => {
869
+ const { root } = keystoreProject()
870
+ const signing = resolveAndroidSigning(join(root, "android"))
871
+ if (!signing) throw new CliError("No release signing configured — `lecodes app keystore create` or `lecodes app keystore use <file>`.")
872
+ log(`${c.green("keystore")} ${signing.storeFile}${c.dim(` (${signing.source})`)}`)
873
+ if (!existsSync(signing.storeFile)) throw new CliError("…and that file does not exist.")
874
+ const password = signing.storePassword ?? await resolveKeystorePassword(flags, false)
875
+ const aliases = listPrivateKeyAliases(keytool(["-list", "-keystore", signing.storeFile, "-storepass:env", KEYTOOL_PASS], { [KEYTOOL_PASS]: password }))
876
+ const alias = signing.keyAlias ?? (aliases.length === 1 ? aliases[0] : undefined)
877
+ log(`${c.green("alias")} ${alias ?? c.yellow("not set")}${aliases.length > 1 ? c.dim(` (keys: ${aliases.join(", ")})`) : ""}`)
878
+ if (!alias) return
879
+ if (!aliases.includes(alias)) throw new CliError(`Key "${alias}" is not in the keystore (it has: ${aliases.join(", ")}).`)
880
+ printCertificate(signing.storeFile, alias, password)
881
+ }
882
+
883
+ /** `lecodes app keystore` — the Android release signing key: create / use <file> / info. */
884
+ export const keystore = defineCommand({
885
+ name: "keystore",
886
+ summary: "Android release signing key: create | use <file> | info",
887
+ description: "The key's path + passwords live in the project .env as LECODES_ANDROID_* (or in android/keystore.properties for Studio-only builds), never in app.json — it only carries the metadata (android.signing). `lecodes app build android` refuses a release without one.",
888
+ commands: [
889
+ defineCommand({
890
+ name: "create",
891
+ summary: "Generate an Android release signing key (keytool), banked in the project .env",
892
+ description: "The keystore path + passwords go to the project .env (LECODES_ANDROID_KEYSTORE_FILE / _KEYSTORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD — loaded by every lecodes run, never pushed), the metadata to app.json android.signing. Refuses to overwrite an existing key or an already configured signing setup unless --force.\n\n" + PLAY_APP_SIGNING_NOTE,
893
+ flags: keystoreCreateFlags,
894
+ examples: ["lecodes app keystore create", "lecodes app keystore create --generate --alias upload"],
895
+ run: ({ flags }) => keystoreCreate(flags),
896
+ }),
897
+ defineCommand({
898
+ name: "use",
899
+ summary: "Attach an existing keystore (any path; --copy puts it in android/)",
900
+ usage: "<file>",
901
+ description: "Reads the keystore's private-key entries with keytool, picks the alias (asked for with --alias when there are several), then banks the path + passwords in the project .env and the metadata in app.json android.signing — exactly what `keystore create` writes. `~/…` paths resolve against the home dir.\n\n" + PLAY_APP_SIGNING_NOTE,
902
+ flags: keystoreUseFlags,
903
+ examples: ["lecodes app keystore use ~/keys/upload.keystore", "lecodes app keystore use release.jks --alias upload --copy"],
904
+ run: ({ args, flags }) => keystoreUse(args[0], flags),
905
+ }),
906
+ defineCommand({
907
+ name: "info",
908
+ summary: "Which key the build signs with + its SHA-1 / SHA-256 fingerprints",
909
+ description: "Resolves signing exactly like the build does (android/keystore.properties first, then the LECODES_ANDROID_* environment the project .env fills) and prints the keystore, the alias and the certificate lines Play / Firebase ask for.",
910
+ flags: passwordFlag,
911
+ examples: ["lecodes app keystore info"],
912
+ run: ({ flags }) => keystoreInfo(flags),
913
+ }),
914
+ ],
915
+ })
916
+
519
917
  /* ---------------------------------------- open ----------------------------------------- */
520
918
 
521
919
  export const openAndroid = async () => {
@@ -593,8 +991,21 @@ export const doctorAndroidChecks = async (
593
991
  } catch { /* offline — not a preflight failure */ }
594
992
  }
595
993
 
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)
994
+ const signing = resolveAndroidSigning(androidDir)
995
+ check(!!signing, "Release signing key", "`lecodes app keystore create` or `keystore use <file>` (release builds are refused without one)", false)
996
+ if (signing) {
997
+ check(existsSync(signing.storeFile), `Keystore ${signing.storeFile}`, `named by ${signing.source} but missing`, false)
998
+ const incomplete = (["storePassword", "keyAlias", "keyPassword"] as const).filter(k => !signing[k])
999
+ check(incomplete.length === 0, `Signing complete (${signing.source})`, `missing ${incomplete.join(", ")}`, false)
1000
+ }
1001
+ check(!!findKeytool(), "keytool", "install a JDK or set JAVA_HOME (`lecodes app keystore …` needs it)", false)
1002
+
1003
+ const appGradle = join(androidDir, "app", "build.gradle.kts")
1004
+ const gradleText = existsSync(appGradle) ? readFileSync(appGradle, "utf8") : ""
1005
+ check(gradleText.includes("abiFilters"), "abiFilters in app/build.gradle.kts",
1006
+ "add `ndk { abiFilters += listOf(\"arm64-v8a\", \"armeabi-v7a\") }` to defaultConfig — x86_64 devices crash at launch without it", false)
1007
+ check(["isMinifyEnabled", "isShrinkResources"].every(k => new RegExp(`${k} = (true|false)`).test(gradleText)), "R8 block in app/build.gradle.kts",
1008
+ "shell predates R8-by-default — see android/README.md \"Building\"", false)
598
1009
 
599
1010
  if (Object.keys(config.plugins ?? {}).includes("push")) {
600
1011
  check(existsSync(join(androidDir, "app", "google-services.json")), "google-services.json present (push)",