lecodes-cli 0.20.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -57
- package/dist/index.js +5920 -5022
- package/package.json +9 -7
- package/runtime/materials/decal-relief.mat +12 -1
- package/runtime/materials/decal.mat +17 -3
- package/runtime/materials/lightmap-baked-lite.mat +6 -1
- package/runtime/materials/lightmap-baked.mat +6 -1
- package/runtime/materials/lightmap.mat +3 -0
- package/runtime/materials/particles-quad.mat +17 -9
- package/runtime/materials/particles.mat +17 -9
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
- package/runtime/web/assets/{index-BTB5KV9x.js → index-Q06c6oHx.js} +1 -1
- package/runtime/web/embed.js +2 -2
- package/src/api.ts +1 -302
- package/src/cli/args.ts +37 -0
- package/src/cli/command.ts +113 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/help.ts +133 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/output.ts +102 -0
- package/src/cli/run.ts +106 -0
- package/src/commands/{appAndroid.ts → app/android.ts} +451 -42
- package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
- package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
- package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
- package/src/commands/{app.ts → app/index.ts} +397 -289
- package/src/commands/{appShared.ts → app/shared.ts} +30 -39
- package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
- package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
- package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
- package/src/commands/assets.ts +23 -21
- package/src/commands/clone.ts +48 -51
- package/src/commands/compile.ts +62 -88
- package/src/commands/create.ts +46 -47
- package/src/commands/design/comments.ts +361 -0
- package/src/commands/design/context.ts +101 -0
- package/src/commands/design/index.ts +391 -0
- package/src/commands/design/snapshot.ts +134 -0
- package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
- package/src/commands/desktop.ts +90 -82
- package/src/commands/dev.ts +201 -176
- package/src/commands/diff.ts +39 -51
- package/src/commands/index.ts +60 -0
- package/src/commands/{init.ts → init/index.ts} +199 -191
- package/src/commands/install.ts +80 -67
- package/src/commands/lightmap.ts +203 -171
- package/src/commands/link.ts +87 -94
- package/src/commands/login.ts +25 -23
- package/src/commands/navmesh.ts +168 -138
- package/src/commands/pn.ts +201 -244
- package/src/commands/pull.ts +49 -56
- package/src/commands/push.ts +48 -54
- package/src/commands/render.ts +158 -99
- package/src/commands/scene.ts +58 -55
- package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
- package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
- package/src/commands/shared.ts +79 -0
- package/src/commands/status.ts +22 -26
- package/src/commands/test.ts +398 -371
- package/src/commands/thumbs.ts +185 -175
- package/src/commands/update/index.ts +226 -0
- package/src/commands/{types.ts → update/types.ts} +31 -22
- package/src/compile/collect.ts +5 -5
- package/src/compile/collectLocal.ts +3 -3
- package/src/compile/designCompile.ts +3 -3
- package/src/compile/headlessBundle.ts +133 -129
- package/src/compile/projectCompile.ts +2 -2
- package/src/compile/sceneCompile.ts +10 -8
- package/src/compile/screenEntry.ts +123 -127
- package/src/compile/shaders.ts +3 -3
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/devServer.ts +3 -3
- package/src/dev/webRunner.ts +1 -1
- package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
- package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
- package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
- package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
- package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
- package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
- package/src/{releases.ts → hosts/releases.ts} +1 -1
- package/src/index.ts +32 -480
- package/src/platform/api.ts +302 -0
- package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
- package/src/{config.ts → platform/config.ts} +1 -1
- package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
- package/src/{projectEnv.ts → project/env.ts} +33 -2
- package/src/{localFiles.ts → project/localFiles.ts} +1 -1
- package/src/{manifest.ts → project/manifest.ts} +1 -1
- package/src/{project.ts → project/materialize.ts} +2 -2
- package/src/project/paths.ts +20 -0
- package/src/{textDiff.ts → project/textDiff.ts} +1 -1
- package/src/{types.ts → project/types.ts} +0 -0
- package/runtime/web/assets/createViewerLite-C_bkKXLg.js +0 -852
- package/src/commands/design.ts +0 -846
- package/src/commands/update.ts +0 -211
- package/src/util.ts +0 -146
- /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
- /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
- /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
- /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
- /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
- /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
- /package/src/{peers.ts → hosts/peers.ts} +0 -0
- /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
- /package/src/{ignore.ts → project/ignore.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 "
|
|
7
|
-
import { compileShell, type BundleResource } from "
|
|
8
|
-
import { CliError,
|
|
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 "./
|
|
14
|
-
import { loadIconMaster, restampManifestIcon, writeAndroidIcons } from "./
|
|
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 "./
|
|
23
|
-
import { GRADLEW_BAT, GRADLEW_SH } from "./
|
|
24
|
-
import { type SdkVariant } from "./
|
|
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
|
|
27
|
-
* instead of Xcode
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
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(
|
|
244
|
+
resources = await compileShell(join(assetsDir, "app.js"), { remotePatterns })
|
|
237
245
|
bundleJs = readFileSync(join(assetsDir, "app.js"), "utf8")
|
|
238
246
|
}
|
|
239
247
|
|
|
@@ -416,6 +424,16 @@ const restampAppBuildGradle = (androidDir: string, config: AppConfig, gradlePlug
|
|
|
416
424
|
warn(`couldn't restamp versionCode in app/build.gradle.kts — set it to ${versionCode} yourself.`)
|
|
417
425
|
}
|
|
418
426
|
|
|
427
|
+
// R8 on/off (app.json android.minify, default on) — the two values travel together: AGP
|
|
428
|
+
// refuses isShrinkResources without isMinifyEnabled.
|
|
429
|
+
const minify = config.android?.minify ?? true
|
|
430
|
+
const r8Keys = ["isMinifyEnabled", "isShrinkResources"]
|
|
431
|
+
if (r8Keys.every(k => new RegExp(`${k} = (true|false)`).test(content))) {
|
|
432
|
+
for (const k of r8Keys) content = content.replace(new RegExp(`${k} = (true|false)`), `${k} = ${minify}`)
|
|
433
|
+
} else {
|
|
434
|
+
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.`)
|
|
435
|
+
}
|
|
436
|
+
|
|
419
437
|
// Which requested Gradle plugins actually get applied: google-services only once the app has
|
|
420
438
|
// its google-services.json (the plugin hard-fails the build without it).
|
|
421
439
|
const applied = new Set<string>()
|
|
@@ -461,7 +479,7 @@ const gradlew = (androidDir: string, tasks: string[]): string => {
|
|
|
461
479
|
const line = `""${join(androidDir, "gradlew.bat")}" ${tasks.join(" ")}"`
|
|
462
480
|
const r = spawnSync("cmd.exe", ["/d", "/s", "/c", line], {
|
|
463
481
|
cwd: androidDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
|
|
464
|
-
maxBuffer: 128 * 1024 * 1024, windowsVerbatimArguments: true,
|
|
482
|
+
maxBuffer: 128 * 1024 * 1024, windowsVerbatimArguments: true, env: childEnv(),
|
|
465
483
|
})
|
|
466
484
|
if (r.status !== 0) {
|
|
467
485
|
const tail = ((r.stdout ?? "") + "\n" + (r.stderr ?? "")).split("\n").filter(l => l.trim()).slice(-25).join("\n")
|
|
@@ -470,7 +488,19 @@ const gradlew = (androidDir: string, tasks: string[]): string => {
|
|
|
470
488
|
return r.stdout ?? ""
|
|
471
489
|
}
|
|
472
490
|
|
|
473
|
-
|
|
491
|
+
const androidBuildFlags = {
|
|
492
|
+
configuration: str("Release (default) or Debug", { value: "<c>", default: "Release" }),
|
|
493
|
+
apk: bool("only the .apk"),
|
|
494
|
+
aab: bool("only the .aab"),
|
|
495
|
+
"no-aab": bool("the old spelling of --apk"),
|
|
496
|
+
"allow-unsigned": bool("build a release without a signing key anyway"),
|
|
497
|
+
out: str("output dir (default dist/)", { alias: "o", value: "<dir>" }),
|
|
498
|
+
slug: str("file name stem (default: the app name)", { value: "<name>" }),
|
|
499
|
+
"no-sync": bool("skip the `lecodes app sync` step"),
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** `lecodes app build android` — sync, Gradle, then the artifacts copied under dist/. */
|
|
503
|
+
const buildAndroid = async (flags: FlagValues<typeof androidBuildFlags>): Promise<void> => {
|
|
474
504
|
const root = projectRoot()
|
|
475
505
|
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
476
506
|
const androidDir = join(root, "android")
|
|
@@ -478,7 +508,43 @@ export const buildAndroid = async (args: Args) => {
|
|
|
478
508
|
throw new CliError("No Android shell here — run `lecodes app init android` first.")
|
|
479
509
|
}
|
|
480
510
|
|
|
481
|
-
|
|
511
|
+
const configuration = flags.configuration.toLowerCase()
|
|
512
|
+
if (configuration !== "release" && configuration !== "debug") {
|
|
513
|
+
throw new CliError(`--configuration must be Release or Debug (got "${configuration}")`)
|
|
514
|
+
}
|
|
515
|
+
// Artifacts: both by default; --apk / --aab pick one (both flags = both); --no-aab = the old
|
|
516
|
+
// spelling of --apk.
|
|
517
|
+
const apkFlag = flags.apk || flags["no-aab"]
|
|
518
|
+
const aabFlag = flags.aab && !flags["no-aab"]
|
|
519
|
+
const wantApk = apkFlag || !aabFlag
|
|
520
|
+
const wantAab = aabFlag || (!apkFlag && !flags["no-aab"])
|
|
521
|
+
|
|
522
|
+
// Signing is resolved HERE, before Gradle runs: a release that would come out unsigned is
|
|
523
|
+
// refused with the ways to fix it, instead of Gradle producing an app-release-unsigned.apk
|
|
524
|
+
// (and an .aab with the same name as a signed one) that can't be installed or uploaded.
|
|
525
|
+
let signing: AndroidSigning | null = null
|
|
526
|
+
if (configuration === "release") {
|
|
527
|
+
signing = resolveAndroidSigning(androidDir)
|
|
528
|
+
if (!signing) {
|
|
529
|
+
if (!flags["allow-unsigned"]) {
|
|
530
|
+
throw new CliError([
|
|
531
|
+
"No release signing key — the .apk/.aab would be unsigned (uninstallable, not uploadable).",
|
|
532
|
+
" lecodes app keystore create make one (path + passwords go to the project .env)",
|
|
533
|
+
" lecodes app keystore use <file> attach an existing keystore",
|
|
534
|
+
" --allow-unsigned build anyway (throwaway builds only)",
|
|
535
|
+
].join("\n"))
|
|
536
|
+
}
|
|
537
|
+
warn("Building UNSIGNED (--allow-unsigned) — the output can't be installed or uploaded.")
|
|
538
|
+
} else if (!existsSync(signing.storeFile)) {
|
|
539
|
+
throw new CliError(`Keystore ${signing.storeFile} (from ${signing.source}) does not exist.`)
|
|
540
|
+
} else {
|
|
541
|
+
const missing = (["storePassword", "keyAlias", "keyPassword"] as const).filter(k => !signing![k])
|
|
542
|
+
if (missing.length > 0) throw new CliError(`Signing from ${signing.source} is incomplete — missing ${missing.join(", ")} (see android/README.md "Release signing").`)
|
|
543
|
+
log(`${c.green("signing")} ${signing.storeFile} alias ${signing.keyAlias}${c.dim(` (${signing.source})`)}`)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!flags["no-sync"]) await syncAndroid(root, config, null)
|
|
482
548
|
|
|
483
549
|
// Gradle needs the Android SDK location: local.properties, or written from the env.
|
|
484
550
|
if (!existsSync(join(androidDir, "local.properties"))) {
|
|
@@ -490,32 +556,362 @@ export const buildAndroid = async (args: Args) => {
|
|
|
490
556
|
note("Wrote android/local.properties (sdk.dir from the environment).")
|
|
491
557
|
}
|
|
492
558
|
|
|
493
|
-
const
|
|
494
|
-
const tasks =
|
|
495
|
-
? ["assembleDebug"]
|
|
496
|
-
: flagBool(args, "no-aab") ? ["assembleRelease"] : ["assembleRelease", "bundleRelease"]
|
|
497
|
-
|
|
559
|
+
const cap = configuration === "release" ? "Release" : "Debug"
|
|
560
|
+
const tasks = [...(wantApk ? [`assemble${cap}`] : []), ...(wantAab ? [`bundle${cap}`] : [])]
|
|
498
561
|
note(`Building (${tasks.join(" ")})… the first run downloads Gradle + dependencies`)
|
|
499
562
|
gradlew(androidDir, tasks)
|
|
500
563
|
|
|
501
|
-
|
|
564
|
+
// Gradle's outputs, then a copy under dist/ named by the app (the desktop build's convention)
|
|
565
|
+
// — the shell's build/ tree stays where Android Studio expects it.
|
|
566
|
+
const produced: string[] = []
|
|
502
567
|
const apkDir = join(androidDir, "app", "build", "outputs", "apk", configuration)
|
|
503
568
|
const aabDir = join(androidDir, "app", "build", "outputs", "bundle", configuration)
|
|
504
|
-
for (const dir of [apkDir, aabDir]) {
|
|
505
|
-
if (!existsSync(dir)) continue
|
|
569
|
+
for (const [dir, want] of [[apkDir, wantApk], [aabDir, wantAab]] as const) {
|
|
570
|
+
if (!want || !existsSync(dir)) continue
|
|
506
571
|
for (const f of readdirSync(dir)) {
|
|
507
|
-
if (f.endsWith(".apk") || f.endsWith(".aab"))
|
|
572
|
+
if (f.endsWith(".apk") || f.endsWith(".aab")) produced.push(join(dir, f))
|
|
508
573
|
}
|
|
509
574
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
575
|
+
if (produced.length === 0) throw new CliError("Gradle finished but produced no .apk/.aab — check android/app/build/outputs.")
|
|
576
|
+
|
|
577
|
+
const slug = flags.slug ?? slugify(config.name ?? basename(root))
|
|
578
|
+
const outDir = resolve(root, flags.out ?? join(root, "dist"))
|
|
579
|
+
mkdirSync(outDir, { recursive: true })
|
|
580
|
+
const stem = `${slug}-${config.version ?? "1.0.0"}-${config.android.versionCode ?? 1}${configuration === "debug" ? "-debug" : ""}`
|
|
581
|
+
for (const src of produced) {
|
|
582
|
+
const unsigned = configuration === "release" && !signing
|
|
583
|
+
const dest = join(outDir, `${stem}${unsigned ? "-unsigned" : ""}${src.endsWith(".aab") ? ".aab" : ".apk"}`)
|
|
584
|
+
cpSync(src, dest)
|
|
585
|
+
const size = (statSync(dest).size / (1024 * 1024)).toFixed(1)
|
|
586
|
+
log(`${c.green("output")} ${relative(root, dest) || dest}${c.dim(` (${size} MB)`)}`)
|
|
513
587
|
}
|
|
514
588
|
success(configuration === "debug"
|
|
515
|
-
? "Debug build done — `adb install` the
|
|
516
|
-
:
|
|
589
|
+
? "Debug build done — `adb install` the .apk to test (or `lecodes dev --android --apk <file>`)."
|
|
590
|
+
: signing
|
|
591
|
+
? "Release build done — upload the .aab in the Play Console (the .apk side-loads for testing)."
|
|
592
|
+
: "Unsigned release build done — sign it before it goes anywhere.")
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export const androidBuild = defineCommand({
|
|
596
|
+
name: "android",
|
|
597
|
+
summary: "Sync, gradlew assembleRelease + bundleRelease, copy the results to dist/<name>-<version>-<versionCode>.apk|.aab",
|
|
598
|
+
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.",
|
|
599
|
+
flags: androidBuildFlags,
|
|
600
|
+
examples: ["lecodes app build android", "lecodes app build android --configuration Debug --apk", "lecodes app build android -o out/"],
|
|
601
|
+
run: ({ flags }) => buildAndroid(flags),
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
/* --------------------------------------- signing --------------------------------------- */
|
|
605
|
+
|
|
606
|
+
/** The four names the generated app/build.gradle.kts reads with System.getenv — the project
|
|
607
|
+
* .env is where they normally live (projectEnv.ts loads it; never pushed). */
|
|
608
|
+
export const ANDROID_SIGNING_ENV = {
|
|
609
|
+
storeFile: "LECODES_ANDROID_KEYSTORE_FILE",
|
|
610
|
+
storePassword: "LECODES_ANDROID_KEYSTORE_PASSWORD",
|
|
611
|
+
keyAlias: "LECODES_ANDROID_KEY_ALIAS",
|
|
612
|
+
keyPassword: "LECODES_ANDROID_KEY_PASSWORD",
|
|
613
|
+
} as const
|
|
614
|
+
|
|
615
|
+
export interface AndroidSigning {
|
|
616
|
+
/** Where `storeFile` came from — per key the file wins, exactly like the Gradle side. */
|
|
617
|
+
source: "keystore.properties" | "env"
|
|
618
|
+
/** Absolute. */
|
|
619
|
+
storeFile: string
|
|
620
|
+
storePassword?: string
|
|
621
|
+
keyAlias?: string
|
|
622
|
+
keyPassword?: string
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** The subset of java.util.Properties a keystore.properties uses: `key=value` / `key: value`,
|
|
626
|
+
* `#` / `!` comments, `\\` `\:` `\=` escapes. */
|
|
627
|
+
const readProperties = (path: string): Record<string, string> => {
|
|
628
|
+
const out: Record<string, string> = {}
|
|
629
|
+
for (const raw of readFileSync(path, "utf8").split(/\r?\n/)) {
|
|
630
|
+
const line = raw.trim()
|
|
631
|
+
if (!line || line.startsWith("#") || line.startsWith("!")) continue
|
|
632
|
+
const m = /^((?:\\.|[^=:\s])+)\s*[=:]?\s*(.*)$/.exec(line)
|
|
633
|
+
if (!m) continue
|
|
634
|
+
const unescape = (v: string) => v.replace(/\\(.)/g, "$1")
|
|
635
|
+
out[unescape(m[1])] = unescape(m[2])
|
|
636
|
+
}
|
|
637
|
+
return out
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** The signing chain exactly as the generated app/build.gradle.kts resolves it: per key,
|
|
641
|
+
* android/keystore.properties first, then the LECODES_ANDROID_* environment (the project .env
|
|
642
|
+
* has been loaded into it). A relative storeFile resolves against android/ like
|
|
643
|
+
* `rootProject.file()` does; `./` values from .env arrive absolute already. Null = no key. */
|
|
644
|
+
export const resolveAndroidSigning = (androidDir: string): AndroidSigning | null => {
|
|
645
|
+
const propsPath = join(androidDir, "keystore.properties")
|
|
646
|
+
const props = existsSync(propsPath) ? readProperties(propsPath) : {}
|
|
647
|
+
const pick = (key: keyof typeof ANDROID_SIGNING_ENV) => props[key] ?? process.env[ANDROID_SIGNING_ENV[key]]
|
|
648
|
+
const storeFile = pick("storeFile")
|
|
649
|
+
if (!storeFile) return null
|
|
650
|
+
return {
|
|
651
|
+
source: props.storeFile ? "keystore.properties" : "env",
|
|
652
|
+
storeFile: resolve(androidDir, storeFile),
|
|
653
|
+
storePassword: pick("storePassword"),
|
|
654
|
+
keyAlias: pick("keyAlias"),
|
|
655
|
+
keyPassword: pick("keyPassword"),
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** keytool: JAVA_HOME → PATH → the JBR inside Android Studio (the only JDK on many machines). */
|
|
660
|
+
const findKeytool = (): string | null => {
|
|
661
|
+
const exe = process.platform === "win32" ? "keytool.exe" : "keytool"
|
|
662
|
+
const fromJavaHome = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, "bin", exe) : null
|
|
663
|
+
if (fromJavaHome && existsSync(fromJavaHome)) return fromJavaHome
|
|
664
|
+
const onPath = spawnSync("keytool", ["-help"], { encoding: "utf8", env: childEnv() })
|
|
665
|
+
if (!onPath.error) return "keytool"
|
|
666
|
+
const studio = process.platform === "win32"
|
|
667
|
+
? [join(process.env.LOCALAPPDATA ?? "", "Programs", "Android Studio", "jbr", "bin", exe), "C:\\Program Files\\Android\\Android Studio\\jbr\\bin\\keytool.exe"]
|
|
668
|
+
: process.platform === "darwin"
|
|
669
|
+
? ["/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/keytool"]
|
|
670
|
+
: [join(homedir(), "android-studio", "jbr", "bin", "keytool"), "/opt/android-studio/jbr/bin/keytool", "/usr/local/android-studio/jbr/bin/keytool"]
|
|
671
|
+
return studio.find(p => existsSync(p)) ?? null
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Run keytool with English output (the alias listing is parsed) and passwords passed through
|
|
675
|
+
* the environment (`-storepass:env`), never on the command line. */
|
|
676
|
+
const keytool = (args: string[], secrets: Record<string, string> = {}): string => {
|
|
677
|
+
const exe = findKeytool()
|
|
678
|
+
if (!exe) throw new CliError("keytool not found — install a JDK (Android Studio ships one) or point JAVA_HOME at it.")
|
|
679
|
+
const r = spawnSync(exe, ["-J-Duser.language=en", ...args], { encoding: "utf8", env: childEnv(secrets), stdio: ["ignore", "pipe", "pipe"] })
|
|
680
|
+
if (r.status !== 0) {
|
|
681
|
+
const tail = ((r.stdout ?? "") + "\n" + (r.stderr ?? "")).split("\n").filter(l => l.trim()).slice(-6).join("\n")
|
|
682
|
+
throw new CliError(`keytool ${args[0]} failed:\n${tail}`)
|
|
683
|
+
}
|
|
684
|
+
return (r.stdout ?? "") + (r.stderr ?? "")
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const KEYTOOL_PASS = "LECODES_KEYTOOL_PASS"
|
|
688
|
+
|
|
689
|
+
/** PrivateKeyEntry aliases from `keytool -list` (English): "<alias>, <date>, PrivateKeyEntry,". */
|
|
690
|
+
const listPrivateKeyAliases = (listing: string): string[] =>
|
|
691
|
+
listing.split(/\r?\n/).map(l => /^(.+?), .*?, PrivateKeyEntry,/.exec(l)?.[1]).filter((a): a is string => !!a)
|
|
692
|
+
|
|
693
|
+
/** How a keystore path is spelled in .env: inside the project → `./…` (resolved against the
|
|
694
|
+
* .env's own dir by projectEnv, so it survives `lecodes` running from a subdirectory); under
|
|
695
|
+
* the home dir → `~/…`; elsewhere absolute. Forward slashes throughout. */
|
|
696
|
+
const envPathFor = (root: string, abs: string): string => {
|
|
697
|
+
const fwd = (p: string) => p.replace(/\\/g, "/")
|
|
698
|
+
const rel = relative(root, abs)
|
|
699
|
+
if (rel && !rel.startsWith("..") && !rel.includes(":")) return `./${fwd(rel)}`
|
|
700
|
+
const home = relative(homedir(), abs)
|
|
701
|
+
if (home && !home.startsWith("..") && !home.includes(":")) return `~/${fwd(home)}`
|
|
702
|
+
return fwd(abs)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** A .env value: raw when plain, double-quoted (with `"` / `\` escaped) when it carries whitespace,
|
|
706
|
+
* `#` or quotes — the subset parseDotEnv unescapes. */
|
|
707
|
+
const envValue = (v: string): string => /[\s#"'\\]/.test(v) ? `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : v
|
|
708
|
+
|
|
709
|
+
/** The store password from --password, --generate (create only), or a hidden prompt. */
|
|
710
|
+
const resolveKeystorePassword = async (opts: { password?: string, generate?: boolean }, create: boolean): Promise<string> => {
|
|
711
|
+
if (opts.password) {
|
|
712
|
+
if (create && opts.password.length < 6) throw new CliError("keytool needs a password of at least 6 characters.")
|
|
713
|
+
return opts.password
|
|
714
|
+
}
|
|
715
|
+
if (create && opts.generate) return randomBytes(24).toString("base64url")
|
|
716
|
+
if (!process.stdin.isTTY) {
|
|
717
|
+
throw new CliError(`No terminal to ask for the keystore password — pass --password <pw>${create ? " (or --generate)" : ""}.`)
|
|
718
|
+
}
|
|
719
|
+
const pw = await promptHidden(create ? "Keystore password (min 6 chars)" : "Keystore password")
|
|
720
|
+
if (create) {
|
|
721
|
+
if (pw.length < 6) throw new CliError("keytool needs a password of at least 6 characters.")
|
|
722
|
+
const again = await promptHidden("Repeat password")
|
|
723
|
+
if (again !== pw) throw new CliError("Passwords don't match.")
|
|
724
|
+
}
|
|
725
|
+
return pw
|
|
517
726
|
}
|
|
518
727
|
|
|
728
|
+
/** Bank the key in .env (+ .gitignore) and its metadata in app.json android.signing. */
|
|
729
|
+
const writeSigningConfig = (root: string, config: AppConfig, storeFile: string, alias: string, storePassword: string, keyPassword: string) => {
|
|
730
|
+
const envPath = envPathFor(root, storeFile)
|
|
731
|
+
const touched = upsertDotEnv(root, {
|
|
732
|
+
[ANDROID_SIGNING_ENV.storeFile]: envPath,
|
|
733
|
+
[ANDROID_SIGNING_ENV.storePassword]: envValue(storePassword),
|
|
734
|
+
[ANDROID_SIGNING_ENV.keyAlias]: alias,
|
|
735
|
+
[ANDROID_SIGNING_ENV.keyPassword]: envValue(keyPassword),
|
|
736
|
+
})
|
|
737
|
+
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)") : ""}`)
|
|
738
|
+
|
|
739
|
+
// A keystore inside the project but outside android/ (which has its own .gitignore) must not
|
|
740
|
+
// end up in git either.
|
|
741
|
+
const rel = relative(root, storeFile)
|
|
742
|
+
if (rel && !rel.startsWith("..") && !rel.includes(":") && !rel.replace(/\\/g, "/").startsWith("android/")) {
|
|
743
|
+
const ignorePath = join(root, ".gitignore")
|
|
744
|
+
const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : ""
|
|
745
|
+
if (!ignore.split("\n").some(l => l.trim() === "*.keystore")) {
|
|
746
|
+
writeFileSync(ignorePath, ignore + (!ignore || ignore.endsWith("\n") ? "" : "\n") + "*.keystore\n")
|
|
747
|
+
log(`${c.green("updated")} .gitignore (+ *.keystore)`)
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const configPath = join(root, "app.json")
|
|
752
|
+
const fresh = readJson<AppConfig>(configPath) ?? config
|
|
753
|
+
fresh.android = { ...(fresh.android ?? {}), signing: { storeFile: envPath, keyAlias: alias } }
|
|
754
|
+
writeJson(configPath, fresh)
|
|
755
|
+
log(`${c.green("written")} app.json (android.signing — metadata only, no passwords)`)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Fingerprints + validity for one alias (`keytool -list -v`, English). */
|
|
759
|
+
const printCertificate = (storeFile: string, alias: string, storePassword: string) => {
|
|
760
|
+
const out = keytool(["-list", "-v", "-keystore", storeFile, "-alias", alias, "-storepass:env", KEYTOOL_PASS], { [KEYTOOL_PASS]: storePassword })
|
|
761
|
+
for (const line of out.split(/\r?\n/)) {
|
|
762
|
+
const t = line.trim()
|
|
763
|
+
if (/^(Alias name|Owner|Valid from|SHA1|SHA256):/.test(t)) log(` ${t}`)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
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."
|
|
768
|
+
|
|
769
|
+
/** Every keystore subcommand starts from the project + its app.json (which may not exist yet). */
|
|
770
|
+
const keystoreProject = (): { root: string, config: AppConfig } => {
|
|
771
|
+
const root = projectRoot()
|
|
772
|
+
return { root, config: readJson<AppConfig>(join(root, "app.json")) ?? {} }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const passwordFlag = { password: str("keystore password (else asked on the terminal)", { value: "<pw>" }) }
|
|
776
|
+
|
|
777
|
+
const keystoreCreateFlags = {
|
|
778
|
+
alias: str("key alias", { value: "<a>", default: "upload" }),
|
|
779
|
+
out: str("keystore path (default android/release.keystore)", { alias: "o", value: "<file>" }),
|
|
780
|
+
...passwordFlag,
|
|
781
|
+
generate: bool("a random password instead of asking"),
|
|
782
|
+
validity: str("certificate validity in days", { value: "<days>", default: "10000" }),
|
|
783
|
+
dname: str("X.500 distinguished name (default CN=<app name>)", { value: "<X.500>" }),
|
|
784
|
+
force: bool("overwrite an existing keystore / signing config"),
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** `lecodes app keystore create` — a new upload key, banked in .env. */
|
|
788
|
+
const keystoreCreate = async (flags: FlagValues<typeof keystoreCreateFlags>): Promise<void> => {
|
|
789
|
+
const { root, config } = keystoreProject()
|
|
790
|
+
const alias = flags.alias
|
|
791
|
+
const storeFile = resolve(root, flags.out ?? join("android", "release.keystore"))
|
|
792
|
+
const validity = flags.validity
|
|
793
|
+
const dname = flags.dname ?? `CN=${(config.name ?? basename(root)).replace(/[,=+<>#;"\\]/g, " ").trim() || "app"}`
|
|
794
|
+
const force = flags.force
|
|
795
|
+
|
|
796
|
+
if (existsSync(storeFile) && !force) {
|
|
797
|
+
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.`)
|
|
798
|
+
}
|
|
799
|
+
const current = resolveAndroidSigning(join(root, "android"))
|
|
800
|
+
if (current && !force) {
|
|
801
|
+
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).`)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const password = await resolveKeystorePassword(flags, true)
|
|
805
|
+
mkdirSync(dirname(storeFile), { recursive: true })
|
|
806
|
+
keytool([
|
|
807
|
+
"-genkeypair", "-keystore", storeFile, "-alias", alias, "-keyalg", "RSA", "-keysize", "2048",
|
|
808
|
+
"-validity", validity, "-dname", dname, "-storepass:env", KEYTOOL_PASS, "-keypass:env", KEYTOOL_PASS,
|
|
809
|
+
], { [KEYTOOL_PASS]: password })
|
|
810
|
+
log(`${c.green("created")} ${relative(root, storeFile)} alias ${alias}, ${dname}, valid ${validity} days`)
|
|
811
|
+
|
|
812
|
+
writeSigningConfig(root, config, storeFile, alias, password, password)
|
|
813
|
+
printCertificate(storeFile, alias, password)
|
|
814
|
+
log("")
|
|
815
|
+
success("Release signing key ready — `lecodes app build android` signs with it.")
|
|
816
|
+
note(PLAY_APP_SIGNING_NOTE)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const keystoreUseFlags = {
|
|
820
|
+
alias: str("key alias (needed when the keystore holds several keys)", { value: "<a>" }),
|
|
821
|
+
...passwordFlag,
|
|
822
|
+
"key-password": str("the key's own password when it differs from the store's (JKS)", { value: "<pw>" }),
|
|
823
|
+
copy: bool("copy the file into android/ and point the config at the copy"),
|
|
824
|
+
force: bool("(--copy) replace a same-named file in android/"),
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** `lecodes app keystore use <file>` — attach an existing keystore. */
|
|
828
|
+
const keystoreUse = async (given: string | undefined, flags: FlagValues<typeof keystoreUseFlags>): Promise<void> => {
|
|
829
|
+
const { root, config } = keystoreProject()
|
|
830
|
+
if (!given) throw new UsageError("Which keystore? `lecodes app keystore use <file>`")
|
|
831
|
+
let storeFile = resolve(root, given.startsWith("~/") ? join(homedir(), given.slice(2)) : given)
|
|
832
|
+
if (!existsSync(storeFile)) throw new CliError(`Keystore not found: ${storeFile}`)
|
|
833
|
+
|
|
834
|
+
const password = await resolveKeystorePassword(flags, false)
|
|
835
|
+
const aliases = listPrivateKeyAliases(keytool(["-list", "-keystore", storeFile, "-storepass:env", KEYTOOL_PASS], { [KEYTOOL_PASS]: password }))
|
|
836
|
+
if (aliases.length === 0) throw new CliError("That keystore holds no private key entry — nothing to sign with.")
|
|
837
|
+
let alias = flags.alias
|
|
838
|
+
if (!alias) {
|
|
839
|
+
if (aliases.length > 1) throw new CliError(`Several keys in the keystore — pick one with --alias: ${aliases.join(", ")}`)
|
|
840
|
+
alias = aliases[0]
|
|
841
|
+
} else if (!aliases.includes(alias)) {
|
|
842
|
+
throw new CliError(`No key "${alias}" in the keystore — it has: ${aliases.join(", ")}`)
|
|
843
|
+
}
|
|
844
|
+
// PKCS12 stores use one password for store and key; a JKS key may have its own.
|
|
845
|
+
const keyPassword = flags["key-password"] ?? password
|
|
846
|
+
|
|
847
|
+
if (flags.copy) {
|
|
848
|
+
const dest = join(root, "android", basename(storeFile))
|
|
849
|
+
if (resolve(dest) !== storeFile) {
|
|
850
|
+
if (existsSync(dest) && !flags.force) throw new CliError(`${relative(root, dest)} already exists — --force to replace it.`)
|
|
851
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
852
|
+
cpSync(storeFile, dest)
|
|
853
|
+
log(`${c.green("copied")} ${relative(root, dest)}`)
|
|
854
|
+
storeFile = dest
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
writeSigningConfig(root, config, storeFile, alias, password, keyPassword)
|
|
859
|
+
printCertificate(storeFile, alias, password)
|
|
860
|
+
log("")
|
|
861
|
+
success(`Release signing uses ${relative(root, storeFile) || storeFile} (alias ${alias}).`)
|
|
862
|
+
note(PLAY_APP_SIGNING_NOTE)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** `lecodes app keystore info` — what the build would sign with, and its fingerprints. */
|
|
866
|
+
const keystoreInfo = async (flags: FlagValues<typeof passwordFlag>): Promise<void> => {
|
|
867
|
+
const { root } = keystoreProject()
|
|
868
|
+
const signing = resolveAndroidSigning(join(root, "android"))
|
|
869
|
+
if (!signing) throw new CliError("No release signing configured — `lecodes app keystore create` or `lecodes app keystore use <file>`.")
|
|
870
|
+
log(`${c.green("keystore")} ${signing.storeFile}${c.dim(` (${signing.source})`)}`)
|
|
871
|
+
if (!existsSync(signing.storeFile)) throw new CliError("…and that file does not exist.")
|
|
872
|
+
const password = signing.storePassword ?? await resolveKeystorePassword(flags, false)
|
|
873
|
+
const aliases = listPrivateKeyAliases(keytool(["-list", "-keystore", signing.storeFile, "-storepass:env", KEYTOOL_PASS], { [KEYTOOL_PASS]: password }))
|
|
874
|
+
const alias = signing.keyAlias ?? (aliases.length === 1 ? aliases[0] : undefined)
|
|
875
|
+
log(`${c.green("alias")} ${alias ?? c.yellow("not set")}${aliases.length > 1 ? c.dim(` (keys: ${aliases.join(", ")})`) : ""}`)
|
|
876
|
+
if (!alias) return
|
|
877
|
+
if (!aliases.includes(alias)) throw new CliError(`Key "${alias}" is not in the keystore (it has: ${aliases.join(", ")}).`)
|
|
878
|
+
printCertificate(signing.storeFile, alias, password)
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** `lecodes app keystore` — the Android release signing key: create / use <file> / info. */
|
|
882
|
+
export const keystore = defineCommand({
|
|
883
|
+
name: "keystore",
|
|
884
|
+
summary: "Android release signing key: create | use <file> | info",
|
|
885
|
+
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.",
|
|
886
|
+
commands: [
|
|
887
|
+
defineCommand({
|
|
888
|
+
name: "create",
|
|
889
|
+
summary: "Generate an Android release signing key (keytool), banked in the project .env",
|
|
890
|
+
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,
|
|
891
|
+
flags: keystoreCreateFlags,
|
|
892
|
+
examples: ["lecodes app keystore create", "lecodes app keystore create --generate --alias upload"],
|
|
893
|
+
run: ({ flags }) => keystoreCreate(flags),
|
|
894
|
+
}),
|
|
895
|
+
defineCommand({
|
|
896
|
+
name: "use",
|
|
897
|
+
summary: "Attach an existing keystore (any path; --copy puts it in android/)",
|
|
898
|
+
usage: "<file>",
|
|
899
|
+
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,
|
|
900
|
+
flags: keystoreUseFlags,
|
|
901
|
+
examples: ["lecodes app keystore use ~/keys/upload.keystore", "lecodes app keystore use release.jks --alias upload --copy"],
|
|
902
|
+
run: ({ args, flags }) => keystoreUse(args[0], flags),
|
|
903
|
+
}),
|
|
904
|
+
defineCommand({
|
|
905
|
+
name: "info",
|
|
906
|
+
summary: "Which key the build signs with + its SHA-1 / SHA-256 fingerprints",
|
|
907
|
+
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.",
|
|
908
|
+
flags: passwordFlag,
|
|
909
|
+
examples: ["lecodes app keystore info"],
|
|
910
|
+
run: ({ flags }) => keystoreInfo(flags),
|
|
911
|
+
}),
|
|
912
|
+
],
|
|
913
|
+
})
|
|
914
|
+
|
|
519
915
|
/* ---------------------------------------- open ----------------------------------------- */
|
|
520
916
|
|
|
521
917
|
export const openAndroid = async () => {
|
|
@@ -593,8 +989,21 @@ export const doctorAndroidChecks = async (
|
|
|
593
989
|
} catch { /* offline — not a preflight failure */ }
|
|
594
990
|
}
|
|
595
991
|
|
|
596
|
-
const
|
|
597
|
-
check(
|
|
992
|
+
const signing = resolveAndroidSigning(androidDir)
|
|
993
|
+
check(!!signing, "Release signing key", "`lecodes app keystore create` or `keystore use <file>` (release builds are refused without one)", false)
|
|
994
|
+
if (signing) {
|
|
995
|
+
check(existsSync(signing.storeFile), `Keystore ${signing.storeFile}`, `named by ${signing.source} but missing`, false)
|
|
996
|
+
const incomplete = (["storePassword", "keyAlias", "keyPassword"] as const).filter(k => !signing[k])
|
|
997
|
+
check(incomplete.length === 0, `Signing complete (${signing.source})`, `missing ${incomplete.join(", ")}`, false)
|
|
998
|
+
}
|
|
999
|
+
check(!!findKeytool(), "keytool", "install a JDK or set JAVA_HOME (`lecodes app keystore …` needs it)", false)
|
|
1000
|
+
|
|
1001
|
+
const appGradle = join(androidDir, "app", "build.gradle.kts")
|
|
1002
|
+
const gradleText = existsSync(appGradle) ? readFileSync(appGradle, "utf8") : ""
|
|
1003
|
+
check(gradleText.includes("abiFilters"), "abiFilters in app/build.gradle.kts",
|
|
1004
|
+
"add `ndk { abiFilters += listOf(\"arm64-v8a\", \"armeabi-v7a\") }` to defaultConfig — x86_64 devices crash at launch without it", false)
|
|
1005
|
+
check(["isMinifyEnabled", "isShrinkResources"].every(k => new RegExp(`${k} = (true|false)`).test(gradleText)), "R8 block in app/build.gradle.kts",
|
|
1006
|
+
"shell predates R8-by-default — see android/README.md \"Building\"", false)
|
|
598
1007
|
|
|
599
1008
|
if (Object.keys(config.plugins ?? {}).includes("push")) {
|
|
600
1009
|
check(existsSync(join(androidDir, "app", "google-services.json")), "google-services.json present (push)",
|