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
@@ -2,14 +2,14 @@ import { execFileSync } from "node:child_process"
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
3
3
  import { homedir } from "node:os"
4
4
  import { dirname, join, resolve } from "node:path"
5
- import { findProjectRoot } from "../manifest"
6
- import { DEFAULT_IGNORE_FILE } from "../ignore"
7
- import { CliError, c, flagStr, log, note, warn, type Args } from "../util"
5
+ import { findProjectRoot } from "../../project/manifest"
6
+ import { DEFAULT_IGNORE_FILE } from "../../project/ignore"
7
+ import { CliError, c, log, note, warn } from "../../cli"
8
8
 
9
- /* Shared plumbing for the `lecodes app` platform arms (app.ts = iOS + dispatch,
10
- * appAndroid.ts = Android). Everything here moved verbatim out of app.ts when the Android
11
- * arm landed — behavior for iOS is unchanged; the SDK helpers just grew a repo parameter
12
- * (lecodes-ios-sdk vs lecodes-android-sdk) and resolvePlugin a platform one. */
9
+ /* Shared plumbing for the `lecodes app` platform arms (index.ts = iOS + the command tree,
10
+ * android.ts = Android, desktop.ts = the portable desktop build) and for every other command that
11
+ * reads app.json (the desktop window, the renderer, native plugin libraries). The SDK helpers take
12
+ * a repo parameter (lecodes-ios-sdk vs lecodes-android-sdk) and resolvePlugin a platform one. */
13
13
 
14
14
  /** First-party plugin registry: id → GitHub repo holding plugins/<id>/. Third-party plugins
15
15
  * use a `github:owner/repo[@tag]` spec in app.json instead (manifest at the repo root). */
@@ -53,13 +53,16 @@ export interface AppConfig {
53
53
  * `variant`: SDK artifact — "auto" (default: sync picks the smallest one covering the engines
54
54
  * the compiled bundle actually uses) or core|2d|3d|full to force one, like ios.variant
55
55
  * (needs lecodes-android-sdk >= 1.3.0 — older releases ship only the full AAR).
56
- * `signing`: keystore METADATA only — the store/key passwords live in android/
57
- * keystore.properties (gitignored) or LECODES_ANDROID_* env vars, never in app.json. */
56
+ * `signing`: keystore METADATA only (written by `lecodes app keystore create|use`) — the
57
+ * path + passwords live in the project .env as LECODES_ANDROID_* (or android/
58
+ * keystore.properties for Studio-only builds), never in app.json.
59
+ * `minify`: R8 on release builds (default true; sync restamps the values in app/build.gradle.kts). */
58
60
  android?: {
59
61
  applicationId?: string, versionCode?: number, sdk?: string, variant?: string,
60
62
  signing?: { storeFile?: string, keyAlias?: string },
63
+ minify?: boolean,
61
64
  }
62
- /** Desktop host (packages/desktop). `window` sizes are LOGICAL units — the host multiplies by
65
+ /** Desktop host (hosts/desktop). `window` sizes are LOGICAL units — the host multiplies by
63
66
  * the display scale. `entry` picks a per-target entry file (default: the detected project
64
67
  * entry) — see docs/desktop-target-plan.md. */
65
68
  desktop?: {
@@ -67,8 +70,8 @@ export interface AppConfig {
67
70
  window?: DesktopWindowConfig,
68
71
  /** Which desktop host binary to run: "gl" (default) or "vulkan" (Windows only, host >= 1.4.0).
69
72
  * A BUILD/DEV-TIME host selection, not a runtime switch — the two are separate binaries with
70
- * their own shaders and different features (the Vulkan one has no 2D engine and no native-view
71
- * plugins). Overridable per run with `--renderer` / LECODES_DESKTOP_RENDERER. */
73
+ * their own shaders and different features (the Vulkan one has no native-view plugins).
74
+ * Overridable per run with `--renderer` / LECODES_DESKTOP_RENDERER. */
72
75
  renderer?: "gl" | "vulkan",
73
76
  /** 3D buffer scale vs the window (0.25–1, host ≥1.4.0). The UI always renders at native
74
77
  * pixels; only the 3D scene is rendered smaller and upscaled by the compositor. Multiplies
@@ -77,6 +80,14 @@ export interface AppConfig {
77
80
  /** 3D buffer cap in px, e.g. [1920, 1080]: on a bigger window/monitor the 3D renders at most
78
81
  * this size (aspect kept) — the per-machine answer to "fullscreen on a 4K display is slow". */
79
82
  maxRenderSize?: [number, number],
83
+ /** Frame-rate CAP for the host's render loop: `"display"` (default — follow the screen's refresh
84
+ * rate, 120 on a ProMotion Mac) or a number, meaning "at most this many fps" (the screen's
85
+ * maximum still wins when it is lower — vsync is the real ceiling, so this can never be a
86
+ * target above it). For an app that wants to spend less: a UI-heavy app on a laptop, a game
87
+ * whose feel is tuned for 60. `desktop run` forwards it as CREATOR_FRAME_RATE (a set env wins);
88
+ * a built .app's host reads it from the staged app.json. Honoured by the macOS host
89
+ * (2026-09-09); the Windows/Linux host is vsync-locked and ignores it for now. */
90
+ frameRate?: number | "display",
80
91
  /** The mouse button acts as a finger: a left click-drag scrolls + flings like touch (the wheel
81
92
  * always scrolls, clicks are unaffected; a button inside a scrollable then gets the touch-style
82
93
  * press feedback delay). For testing the touch experience without a touchscreen, or an app
@@ -88,13 +99,13 @@ export interface AppConfig {
88
99
  * a built folder's host reads it from the staged app.json. Headless render/test ignore it. */
89
100
  virtualKeyboard?: boolean | DesktopVirtualKeyboard,
90
101
  /** The macOS .app build (`lecodes app build desktop` on a Mac) — identity + signing +
91
- * notarization. See appDesktopMac.ts for the whole story. */
102
+ * notarization. See desktopMac.ts for the whole story. */
92
103
  macos?: MacDesktopConfig,
93
104
  }
94
105
  }
95
106
 
96
107
  /** One style slot of the desktop on-screen keyboard — the SDK's style words, the subset the drawn
97
- * keyboard reads. Unset fields keep the theme preset. */
108
+ * keyboard reads (colors: any CSS color string). Unset fields keep the theme preset. */
98
109
  export interface DesktopKeyboardSlot {
99
110
  backgroundColor?: string
100
111
  color?: string
@@ -181,13 +192,14 @@ export const readAppConfigAt = (root: string): AppConfig | null =>
181
192
  export const DESKTOP_DEFAULT_WINDOW = { width: 1280, height: 720 }
182
193
 
183
194
  /**
184
- * The renderer for this run: `--renderer` > LECODES_DESKTOP_RENDERER > app.json `desktop.renderer`
185
- * > "gl". Resolve it ONCE per command and pass the result to both `resolveDesktopExe` and the
186
- * shader staging — two independent reads of the config is how a build ends up with a Vulkan exe
187
- * next to an OpenGL .filamat, which fails at the first material with a Filament abort.
195
+ * The renderer for this run: the `--renderer` flag > LECODES_DESKTOP_RENDERER > app.json
196
+ * `desktop.renderer` > "gl". Resolve it ONCE per command and pass the result to both
197
+ * `resolveDesktopExe` and the shader staging — two independent reads of the config is how a build
198
+ * ends up with a Vulkan exe next to an OpenGL .filamat, which fails at the first material with a
199
+ * Filament abort.
188
200
  */
189
- export const resolveDesktopRenderer = (root: string, args?: Args): "gl" | "vulkan" => {
190
- const raw = (args ? flagStr(args, "renderer") : undefined)
201
+ export const resolveDesktopRenderer = (root: string, renderer?: string): "gl" | "vulkan" => {
202
+ const raw = renderer
191
203
  ?? process.env.LECODES_DESKTOP_RENDERER
192
204
  ?? readAppConfigAt(root)?.desktop?.renderer
193
205
  if (raw === undefined) return "gl"
@@ -270,8 +282,8 @@ export interface PluginManifest {
270
282
  * Bare ids resolve through the CLI's known-version table; "id:version" pins explicitly. */
271
283
  gradlePlugins?: string[],
272
284
  } | null
273
- /** Desktop host (packages/desktop): a PREBUILT shared library per OS/arch behind the C ABI in
274
- * `packages/desktop/plugin-sdk/lecodes_plugin.h` — no host rebuild, no source vendoring.
285
+ /** Desktop host (hosts/desktop): a PREBUILT shared library per OS/arch behind the C ABI in
286
+ * `hosts/desktop/plugin-sdk/lecodes_plugin.h` — no host rebuild, no source vendoring.
275
287
  * `library` keys are `${process.platform}-${process.arch}` ("win32-x64", "linux-x64",
276
288
  * "linux-arm64"), values plugin-dir-relative paths. `lecodes desktop run` / `render --desktop`
277
289
  * forward the library's dir to the host (CREATOR_PLUGIN_DIRS); `app build desktop` stages the
@@ -303,10 +315,16 @@ export const git = (cwd: string, ...args: string[]): string =>
303
315
 
304
316
  export const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "app"
305
317
 
318
+ /** The environment for a child process. Spelled out on every spawn on purpose: Bun hands an
319
+ * inherited child the environment SNAPSHOT taken at startup, so anything the project .env
320
+ * contributed (projectEnv.ts — keystore passwords, CREATOR_* tracing) would never arrive. */
321
+ export const childEnv = (extra: Record<string, string> = {}): Record<string, string | undefined> =>
322
+ ({ ...process.env, ...extra })
323
+
306
324
  /** Run a tool, capturing output; on failure surface the tail (xcodebuild/gradle logs are huge). */
307
325
  export const runTool = (cwd: string, cmd: string, args: string[]): string => {
308
326
  try {
309
- return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 128 * 1024 * 1024 })
327
+ return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 128 * 1024 * 1024, env: childEnv() })
310
328
  } catch (e) {
311
329
  const err = e as { stdout?: string, stderr?: string }
312
330
  const tail = ((err.stdout ?? "") + "\n" + (err.stderr ?? "")).split("\n").filter(l => l.trim()).slice(-25).join("\n")
@@ -329,7 +347,7 @@ export const ensureShellIgnored = (root: string, dir: string) => {
329
347
  /* ----------------------------------- SDK variants ----------------------------------- */
330
348
 
331
349
  // Both native SDKs ship the same variant matrix (full / 3d / 2d / core), so the detection and
332
- // resolution logic is shared between the iOS (app.ts) and Android (appAndroid.ts) syncs.
350
+ // resolution logic is shared between the iOS (index.ts) and Android (android.ts) syncs.
333
351
  export const SDK_VARIANTS = ["core", "2d", "3d", "full"] as const
334
352
  type SdkVariantShared = (typeof SDK_VARIANTS)[number]
335
353
 
@@ -346,25 +364,6 @@ export const detectEngines = (bundleJs: string): { gl: boolean, g2: boolean } =>
346
364
  return { gl: !/^\/\/ gl: disable$/m.test(header), g2: /^\/\/ 2d: enable$/m.test(header) }
347
365
  }
348
366
 
349
- /**
350
- * Refuse a Vulkan desktop build for a project that uses the 2D engine. The Vulkan host is built with
351
- * CREATOR_PKG_2D=OFF (creator-2d is sokol-on-GL, with no Vulkan twin), so those scenes don't render
352
- * — a blank window at runtime with nothing in the log to explain it. Fail at build time instead,
353
- * where the fix ("use the default renderer") is one line away.
354
- *
355
- * Detection is the same `// 2d: enable` bundle header `lecodes app sync` picks its SDK variant from,
356
- * so it can't drift from what the engine actually needs.
357
- */
358
- export const assertRendererSupportsBundle = (renderer: "gl" | "vulkan", bundleJs: string): void => {
359
- if (renderer !== "vulkan") return
360
- if (!detectEngines(bundleJs).g2) return
361
- throw new CliError(
362
- "This project uses the 2D engine, which the Vulkan desktop host doesn't include " +
363
- "(creator-2d renders through OpenGL and has no Vulkan path yet).\n" +
364
- " Build with the default renderer: drop `desktop.renderer` from app.json, or pass `--renderer gl`.",
365
- )
366
- }
367
-
368
367
  /** The SDK product for a shell: the smallest variant covering the engines the compiled bundle
369
368
  * actually uses, unless <configKey> (ios.variant / android.variant) forces one — then warn when
370
369
  * the forced product can't run the project (missing engine = those scenes silently no-op on
@@ -542,7 +541,7 @@ const isDesktopPluginLibrary = (name: string): boolean => {
542
541
  return process.platform === "win32" ? n.endsWith(".dll") : n.endsWith(".so")
543
542
  }
544
543
 
545
- /** Native plugin libraries for THIS project on THIS machine (packages/desktop/plugin-sdk/
544
+ /** Native plugin libraries for THIS project on THIS machine (hosts/desktop/plugin-sdk/
546
545
  * lecodes_plugin.h — runtime-loaded .dll/.so, no host rebuild):
547
546
  * - every library in the project's own `plugins/` dir — the zero-manifest dev loop (build a
548
547
  * DLL, drop it there, `lecodes desktop run`);
@@ -1,5 +1,5 @@
1
1
  /* Templates for `lecodes app init android` / `lecodes app sync` — the Android mirror of
2
- * appTemplates.ts, distilled from the LetaryCreatorViewer host (lecodes-android :app). The
2
+ * ios.ts, distilled from the LetaryCreatorViewer host (lecodes-android :app). The
3
3
  * same three ownership tiers: app/ is USER-owned after generation (init writes once, sync
4
4
  * never touches it — except app/src/main/assets/, the Resources/ analog, and values-only
5
5
  * versionName/versionCode restamps in app/build.gradle.kts); settings/root gradle files,
@@ -144,20 +144,18 @@ dependencies {
144
144
 
145
145
  /** lecodes-runtime/src/main/kotlin/io/letary/lecodes/runtime/LeCodesRuntime.kt — the generated
146
146
  * registration + host-hook surface. Plugins are called by FQN (no import collisions); the
147
- * activity hooks are always present and inert without a consuming plugin (the Android analog
148
- * of iOS's buffered LeCodesAppHooks — here the only launch-time state, a notification tap's
149
- * intent extra, survives until the host forwards it, so no buffering is needed). */
147
+ * activity hooks forward to the SDK's LeCodesAppHooks, which NAMES NO PLUGIN — a plugin that
148
+ * consumes activity moments (push: a notification tap is an intent) attaches its handler there. */
150
149
  export const runtimeKotlinTemplate = (plugins: AndroidRuntimePlugin[], projectUuid?: string): string => {
151
150
  const identity = projectUuid ? ` engine.bootProjectUuid = "${projectUuid}"\n` : ""
152
151
  const calls = plugins.map(p => ` ${p.register}.register(engine, context)\n`).join("")
153
152
  const body = identity + calls
154
- const push = plugins.find(p => p.id === "push")
155
- const pushNs = push ? push.register.split(".").slice(0, -1).join(".") : null
156
153
  return `// Generated by \`lecodes app sync\` — regenerated on every sync, do not edit.
157
154
  package io.letary.lecodes.runtime
158
155
 
159
156
  import android.content.Context
160
157
  import android.content.Intent
158
+ import io.letary.lecodes.LeCodesAppHooks
161
159
  import io.letary.lecodes.LecodesEngine
162
160
 
163
161
  object LeCodesRuntime {
@@ -170,16 +168,17 @@ ${body === "" ? " // no plugins installed\n" : body} }
170
168
  /** Cold create only (savedInstanceState == null): forward the launch intent so a plugin
171
169
  * can park launch-time state (a push notification tap) before the engine boots. */
172
170
  fun onActivityLaunch(intent: Intent) {
173
- ${push ? ` ${pushNs}.PushPlugin.parkLaunchPayload(intent)\n` : " // no plugin consumes launch intents\n"} }
171
+ LeCodesAppHooks.launchIntent(intent)
172
+ }
174
173
 
175
174
  /** Warm intent (Activity.onNewIntent). @return true when a plugin consumed it — the
176
175
  * caller must then SKIP its generic deep-link handling. */
177
- fun onNewIntent(intent: Intent): Boolean {
178
- ${push ? ` return ${pushNs}.PushPlugin.handleTapIntent(intent)\n` : " return false\n"} }
176
+ fun onNewIntent(intent: Intent): Boolean = LeCodesAppHooks.newIntent(intent)
179
177
 
180
178
  /** Activity.onResume(true) / onPause(false). */
181
179
  fun setForeground(foreground: Boolean) {
182
- ${push ? ` ${pushNs}.PushPlugin.setForeground(foreground)\n` : " // no plugin tracks foreground state\n"} }
180
+ LeCodesAppHooks.foreground(foreground)
181
+ }
183
182
  }
184
183
  `
185
184
  }
@@ -234,16 +233,17 @@ export const ANDROID_README = `# Android shell
234
233
  Generated by \`lecodes app init android\`. Daily workflow:
235
234
 
236
235
  \`\`\`
237
- lecodes app sync # embed the compiled bundle + plugins into this shell
238
- lecodes app open android # open in Android Studio
239
- lecodes app build android # signed release APK + AAB (gradlew assembleRelease bundleRelease)
236
+ lecodes app sync # embed the compiled bundle + plugins into this shell
237
+ lecodes app open android # open in Android Studio
238
+ lecodes app keystore create # once: the release signing key (or \`keystore use <file>\`)
239
+ lecodes app build android # signed release .apk + .aab → ../dist/
240
240
  \`\`\`
241
241
 
242
242
  ## Ownership
243
243
 
244
244
  | Path | Owner |
245
245
  | --- | --- |
246
- | \`app/\` (sources, manifest, res, build.gradle.kts) | **Yours** after init — sync never rewrites them (it only restamps \`versionName\`/\`versionCode\` values, manages \`// lecodes:plugin\`-tagged lines in the plugins block, and — when \`app.json\` sets \`"icon"\` — the icon files below) |
246
+ | \`app/\` (sources, manifest, res, build.gradle.kts, proguard-rules.pro) | **Yours** after init — sync never rewrites them (it only restamps the \`versionName\`/\`versionCode\` and \`isMinifyEnabled\`/\`isShrinkResources\` values, manages \`// lecodes:plugin\`-tagged lines in the plugins block, and — when \`app.json\` sets \`"icon"\` — the icon files below) |
247
247
  | \`app/src/main/assets/\` | CLI-owned — wiped and regenerated by every sync (the compiled \`app.js\` + its \`// preload:\` assets) |
248
248
  | \`res/mipmap-*/ic_launcher*\`, \`res/values/lecodes_icon.xml\` | CLI-owned **only when** \`app.json\` sets \`"icon"\` — regenerated by every sync (plus a value-only \`android:icon\` restamp in the manifest); without the key sync never touches icons |
249
249
  | \`settings.gradle.kts\`, \`build.gradle.kts\` (root), \`gradle.properties\`, \`lecodes-runtime/\`, \`plugins/\` | CLI-owned — regenerated by every sync |
@@ -280,23 +280,48 @@ dependencies they need to \`app/build.gradle.kts\` — that file is yours.
280
280
 
281
281
  ## Release signing
282
282
 
283
- Create \`android/keystore.properties\` (gitignored):
283
+ The key lives in the project's \`.env\` (next to \`app.json\`; the CLI loads it for every command
284
+ and never pushes it):
284
285
 
285
286
  \`\`\`
286
- storeFile=release.keystore
287
- storePassword=…
288
- keyAlias=…
289
- keyPassword=…
287
+ LECODES_ANDROID_KEYSTORE_FILE=./android/release.keystore # ./ = the project root; ~/ and absolute paths work too
288
+ LECODES_ANDROID_KEYSTORE_PASSWORD=…
289
+ LECODES_ANDROID_KEY_ALIAS=upload
290
+ LECODES_ANDROID_KEY_PASSWORD=…
290
291
  \`\`\`
291
292
 
292
- or export \`LECODES_ANDROID_KEYSTORE_FILE\` / \`LECODES_ANDROID_KEYSTORE_PASSWORD\` /
293
- \`LECODES_ANDROID_KEY_ALIAS\` / \`LECODES_ANDROID_KEY_PASSWORD\`. Never put passwords in app.json.
293
+ \`lecodes app keystore create\` makes a new key and writes those lines; \`lecodes app keystore use
294
+ <file>\` attaches an existing one (a keystore outside the project is just a path); \`lecodes app
295
+ keystore info\` prints the SHA-1 / SHA-256 fingerprints the Play Console, Firebase and Google
296
+ Sign-In ask for. The same four names work as real environment variables (CI). Building from
297
+ Android Studio without the CLI? \`android/keystore.properties\` (gitignored, \`storeFile\` /
298
+ \`storePassword\` / \`keyAlias\` / \`keyPassword\`) is still honoured and wins over the env.
299
+ Never put passwords in app.json — \`android.signing\` there is metadata only.
300
+
301
+ With Play App Signing (the default for new Play apps) this key is the UPLOAD key — Google holds
302
+ the app signing key and a lost upload key can be reset through Play support. Without it, losing
303
+ the keystore means the app can never be updated. Back it up either way.
304
+
305
+ ## Building
306
+
307
+ \`lecodes app build android\` syncs, runs \`gradlew assembleRelease bundleRelease\` and copies the
308
+ results to \`../dist/<name>-<version>-<versionCode>.apk|.aab\` (\`-o <dir>\` elsewhere). A release
309
+ without a signing key is refused (\`--allow-unsigned\` for a throwaway build); \`--configuration
310
+ Debug\` signs with the debug keystore. \`--apk\` / \`--aab\` pick one artifact.
311
+
312
+ Release builds run R8 (\`isMinifyEnabled\` + \`isShrinkResources\`): the SDK AAR ships the keep
313
+ rules its JNI bindings need, plugins ship theirs, \`app/proguard-rules.pro\` is for your own
314
+ classes. \`app.json\` \`"android": { "minify": false }\` turns it off (sync restamps the values).
315
+ R8 only touches the dex — the engine's native libraries and \`app.js\` are not shrunk.
316
+
317
+ \`abiFilters\` in \`app/build.gradle.kts\` keeps the package to the two ABIs the SDK ships
318
+ (arm64-v8a, armeabi-v7a).
294
319
 
295
320
  ## Icons
296
321
 
297
322
  The easy path: set \`app.json\` \`"icon"\` to a square ≥1024px PNG/JPEG in the project (e.g.
298
- \`"icon": "icon.png"\`, optional \`"iconBackground": "#RRGGBB"\` — the adaptive-icon backdrop,
299
- default white). Every \`lecodes app sync\` then renders the full launcher set: legacy density
323
+ \`"icon": "icon.png"\`, optional \`"iconBackground": "#RRGGBB"\` (any opaque CSS color) — the adaptive-icon
324
+ backdrop, default white). Every \`lecodes app sync\` then renders the full launcher set: legacy density
300
325
  PNGs, the API 26+ adaptive icon (your image at 72dp on the 108dp canvas), and an Android 13
301
326
  themed-icon (monochrome) layer that reuses the foreground silhouette.
302
327
 
@@ -314,11 +339,11 @@ the init-time placeholder.
314
339
 
315
340
  /* ---------------------------- USER-owned (written once) ---------------------------- */
316
341
 
317
- export const appBuildGradleTemplate = ({ applicationId, version, versionCode }: AndroidShellIdentity): string => `import java.util.Properties
342
+ export const appBuildGradleTemplate = ({ applicationId, version, versionCode }: AndroidShellIdentity, minify = true): string => `import java.util.Properties
318
343
 
319
344
  // Generated by \`lecodes app init\` — yours to edit. \`lecodes app sync\` only restamps the
320
- // versionName/versionCode VALUES and manages \`// lecodes:plugin\`-tagged lines in the plugins
321
- // block below; everything else is left alone.
345
+ // versionName/versionCode and isMinifyEnabled/isShrinkResources VALUES and manages
346
+ // \`// lecodes:plugin\`-tagged lines in the plugins block below; everything else is left alone.
322
347
  plugins {
323
348
  id("com.android.application")
324
349
  id("org.jetbrains.kotlin.android")
@@ -342,6 +367,9 @@ android {
342
367
  targetSdk = ${TARGET_SDK}
343
368
  versionCode = ${versionCode}
344
369
  versionName = "${version}"
370
+ // The LeCodes SDK ships arm64-v8a + armeabi-v7a natives. A transitive dependency adds a
371
+ // lone x86_64 library, which would make the package claim an ABI the engine can't load on.
372
+ ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
345
373
  }
346
374
 
347
375
  val storeFilePath = signing("storeFile", "LECODES_ANDROID_KEYSTORE_FILE")
@@ -356,7 +384,12 @@ android {
356
384
 
357
385
  buildTypes {
358
386
  release {
359
- isMinifyEnabled = false
387
+ // R8 (shrink + optimize + obfuscate). The SDK AAR carries the keep rules its JNI
388
+ // bindings need; app-local rules go in proguard-rules.pro. app.json
389
+ // android.minify: false turns it off (sync restamps these two values).
390
+ isMinifyEnabled = ${minify}
391
+ isShrinkResources = ${minify}
392
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
360
393
  signingConfig = signingConfigs.findByName("release")
361
394
  }
362
395
  }
@@ -378,6 +411,18 @@ dependencies {
378
411
  }
379
412
  `
380
413
 
414
+ /** app/proguard-rules.pro — user-owned R8 rules for app-local code. The SDK's own keep rules
415
+ * (its JNI-bound classes) ride inside the AAR as consumer rules, and plugin modules ship theirs
416
+ * through consumerProguardFiles, so this file starts empty. */
417
+ export const APP_PROGUARD_RULES = `# R8 rules for app-local code (yours — \`lecodes app sync\` never touches this file).
418
+ #
419
+ # The LeCodes SDK keeps its own JNI-bound classes through the consumer rules inside the AAR, and
420
+ # plugin modules bring their own. Add rules here only for classes YOUR Kotlin registers with the
421
+ # engine by name (engine.registerCallback / registerService / registerView) or reaches through
422
+ # reflection, e.g.
423
+ # -keep class com.example.myapp.MyBridge { *; }
424
+ `
425
+
381
426
  export const appManifestTemplate = (): string => `<?xml version="1.0" encoding="utf-8"?>
382
427
  <!-- Generated by \`lecodes app init\` — yours to edit. Sync leaves it alone, except that with
383
428
  app.json "icon" set it restamps the android:icon VALUE to the generated @mipmap set.
@@ -404,18 +449,6 @@ export const appManifestTemplate = (): string => `<?xml version="1.0" encoding="
404
449
  <action android:name="android.intent.action.MAIN" />
405
450
  <category android:name="android.intent.category.LAUNCHER" />
406
451
  </intent-filter>
407
-
408
- <!-- le.codes/qr deep links (QR scans, notification taps). Editable — remove if the
409
- app should not claim them. -->
410
- <intent-filter android:autoVerify="true">
411
- <action android:name="android.intent.action.VIEW" />
412
- <category android:name="android.intent.category.DEFAULT" />
413
- <category android:name="android.intent.category.BROWSABLE" />
414
- <data android:scheme="https" />
415
- <data android:scheme="http" />
416
- <data android:host="le.codes" />
417
- <data android:pathPrefix="/qr" />
418
- </intent-filter>
419
452
  </activity>
420
453
  </application>
421
454
 
@@ -1,5 +1,5 @@
1
1
  /* Gradle wrapper launcher scripts, verbatim from the gradle/gradle v8.13.0 tag (the same
2
- * pinned source as the wrapper jar download in appAndroid.ts). JSON-escaped mechanically —
2
+ * pinned source as the wrapper jar download in ../android.ts). JSON-escaped mechanically —
3
3
  * regenerate from the tag rather than editing. gradlew must be written with LF and
4
4
  * gradlew.bat with CRLF (initAndroid handles the conversion); both are version-agnostic
5
5
  * launchers that read gradle/wrapper/gradle-wrapper.properties for the real distribution. */
@@ -1,4 +1,4 @@
1
- import { swiftPackageName, type IosPackage } from "./appShared"
1
+ import { swiftPackageName, type IosPackage } from "../shared"
2
2
 
3
3
  /* Templates for `lecodes app init ios` / `lecodes app sync` — distilled 1:1 from the
4
4
  * hand-built, device-verified reference shell (docs/xcode-app-plan.md). Three ownership
@@ -485,7 +485,7 @@ export const VIEW_CONTROLLER = `//
485
485
  // Generated by lecodes app init — yours to edit.
486
486
  //
487
487
  // Runs the bundled app.js (produced by \`lecodes app sync\`) offline: everything the
488
- // script needs is provided by the SDK core (_creatorUI / _creatorUtils / console)
488
+ // script needs is provided by the SDK core (_creatorTree / the host bridges / console)
489
489
  // plus the plugins registered below via LeCodesRuntime.
490
490
  //
491
491
 
@@ -1,24 +1,26 @@
1
- import { type Args } from "../util"
2
- import { resolveCmgenExe } from "../cmgenTool"
3
- import { loadPeer } from "../peerInstall"
1
+ import { defineCommand } from "../cli"
2
+ import { resolveCmgenExe } from "../hosts/cmgenTool"
3
+ import { loadPeer } from "../hosts/peerInstall"
4
4
 
5
5
  /*
6
- * `lecodes assets <convert|probe|doctor|sky> …` — the LeCodes asset pipeline (packages/lecodes-assets):
7
- * FBX → GLB (ufbx wasm + glTF-Transform), `--clips` merged by bone name, textures embedded, GLB
8
- * doctor (bone cap / texture normalize / KTX2). The package is an optional peer (like the design
9
- * board and the renderer); its own argv parser handles the sub-command flags, so the raw argv is
10
- * forwarded untouched (`assets` gets `_raw` from main). See docs/animation-plan.md §2.9.
6
+ * `lecodes assets …` — the asset pipeline (tools/assets): FBX → GLB, the GLB doctor,
7
+ * retargeting, Unity packs, skies. The package is an optional peer with its OWN argv parser and
8
+ * help, so the argv is forwarded untouched (`passthrough`) — `lecodes assets --help` is theirs.
11
9
  */
12
- export const assets = async (args: Args & { _raw?: string[] }) => {
13
- const mod = await loadPeer<typeof import("lecodes-assets/cli")>("lecodes-assets", "cli", { for: "lecodes assets" })
14
- const raw = args._raw ?? args._
15
- // `sky` shells out to filament's cmgen, which the asset package deliberately never downloads
16
- // itself (it stays network-free and usable standalone). The CLI owns release artifacts, so it
17
- // resolves the binary — cache or first-use download — and hands it over through the env var the
18
- // package already honours. An explicit LECODES_CMGEN wins, so a local filament build still rules.
19
- if (raw[0] === "sky" && !process.env.LECODES_CMGEN) {
20
- process.env.LECODES_CMGEN = await resolveCmgenExe()
21
- }
22
- const code = await mod.runAssetsCli(raw)
23
- if (code !== 0) process.exitCode = code
24
- }
10
+ export default defineCommand({
11
+ name: "assets",
12
+ summary: "The asset pipeline: convert / probe / doctor / retarget / unpack / scene / sky / terrain-pack …",
13
+ usage: "<command> [args…]",
14
+ passthrough: true,
15
+ examples: ["lecodes assets convert hero.fbx --clips anims/*.fbx -o hero.glb", "lecodes assets doctor hero.glb --fix --ktx2", "lecodes assets --help"],
16
+ run: async ({ argv }) => {
17
+ const mod = await loadPeer<typeof import("lecodes-assets/cli")>("lecodes-assets", "cli", { for: "lecodes assets" })
18
+ // `sky` shells out to filament's cmgen, which the asset package deliberately never downloads
19
+ // itself (it stays network-free and usable standalone). The CLI owns release artifacts, so it
20
+ // resolves the binary — cache or first-use download — and hands it over through the env var the
21
+ // package already honours. An explicit LECODES_CMGEN wins, so a local filament build still rules.
22
+ if (argv[0] === "sky" && !process.env.LECODES_CMGEN) process.env.LECODES_CMGEN = await resolveCmgenExe()
23
+ const code = await mod.runAssetsCli(argv)
24
+ if (code !== 0) process.exitCode = code
25
+ },
26
+ })
@@ -1,60 +1,57 @@
1
1
  import { existsSync, mkdirSync, readdirSync } from "node:fs"
2
2
  import { resolve } from "node:path"
3
- import { getCommits, getProject } from "../api"
4
- import { loadConfig, requireApiUrl, requireToken } from "../config"
5
- import { writeManifest } from "../manifest"
6
- import { materializeAssets } from "../project"
7
- import { materializeTypes, writeTsconfig } from "../types"
8
- import { ensureGitignore, writeDefaultIgnore } from "../ignore"
9
- import { CliError, c, info, success, progress, endProgress, flagBool, type Args } from "../util"
10
-
11
- /* `lecodes clone <uuid|url> [dir]` — download a project's files + types into a new folder. */
12
-
13
- /**
14
- * Accept a bare uuid or a project link and pull out the uuid. Handles editor links
15
- * (https://le.codes/app/projects/<uuid>) and viewer links (.../view/<uuid>), with any
16
- * query/hash or trailing slash; falls back to the last path segment.
17
- */
3
+ import { CliError, UsageError, bool, c, defineCommand, endProgress, info, progress, success } from "../cli"
4
+ import { getCommits, getProject } from "../platform/api"
5
+ import { ensureGitignore, writeDefaultIgnore } from "../project/ignore"
6
+ import { writeManifest } from "../project/manifest"
7
+ import { materializeAssets } from "../project/materialize"
8
+ import { materializeTypes, writeTsconfig } from "../project/types"
9
+ import { session, slugOf } from "./shared"
10
+
11
+ /* `lecodes clone <uuid|url> [dir]` — a project's files + types into a new folder. */
12
+
13
+ /** A bare uuid or a project link (editor `/app/projects/<uuid>`, viewer `/view/<uuid>`, any query /
14
+ * hash / trailing slash) → the uuid; falls back to the last path segment. */
18
15
  export const parseUuid = (input: string): string => {
19
- if (!input) throw new CliError("Usage: lecodes clone <project-uuid|url> [directory]")
16
+ if (!input) throw new UsageError("a project uuid or url is required")
20
17
  if (!input.includes("/")) return input
21
18
  const clean = input.split(/[?#]/)[0].replace(/\/+$/, "")
22
19
  const match = clean.match(/\/(?:projects|view)\/([^/]+)/)
23
20
  return match ? match[1] : clean.slice(clean.lastIndexOf("/") + 1)
24
21
  }
25
22
 
26
- const slug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project"
27
-
28
- export const clone = async (args: Args) => {
29
- const config = loadConfig()
30
- const apiUrl = requireApiUrl(config)
31
- const token = requireToken(config)
32
-
33
- const uuid = parseUuid(args._[0])
34
- const project = await getProject(apiUrl, token, uuid)
35
-
36
- const dir = args._[1] ?? slug(project.name)
37
- const root = resolve(dir)
38
- if (existsSync(root) && readdirSync(root).length > 0) {
39
- throw new CliError(`Directory "${dir}" already exists and is not empty.`)
40
- }
41
- mkdirSync(root, { recursive: true })
42
-
43
- info(c.dim(`Fetching project ${c.bold(project.name)}…`))
44
- const files = await materializeAssets(root, project, apiUrl, token, progress)
45
- endProgress()
46
-
47
- if (!flagBool(args, "no-types")) {
48
- info(c.dim("Fetching type definitions…"))
49
- await materializeTypes(root, apiUrl)
50
- writeTsconfig(root)
51
- }
52
- writeDefaultIgnore(root)
53
- ensureGitignore(root) // .lecodes/ is per-checkout state — keep it out of the project's git repo
54
-
55
- const commits = await getCommits(apiUrl, token, uuid)
56
- writeManifest(root, { uuid, apiUrl, name: project.name, baseHeadId: commits.headId, files })
57
-
58
- success(`Cloned ${c.bold(project.name)} into ${c.bold(dir)}/ (${Object.keys(files).length} files)`)
59
- info(c.dim(` cd ${dir} — edit, then \`lecodes push -m "your message"\``))
60
- }
23
+ export default defineCommand({
24
+ name: "clone",
25
+ summary: "Download a project's files + types into a new folder",
26
+ usage: "<uuid|url> [dir]",
27
+ flags: { "no-types": bool("skip the .d.ts / tsconfig generation") },
28
+ examples: ["lecodes clone https://le.codes/app/projects/<uuid> myapp"],
29
+ run: async ({ args, flags }) => {
30
+ const { apiUrl, token } = session()
31
+ const uuid = parseUuid(args[0])
32
+ const project = await getProject(apiUrl, token, uuid)
33
+
34
+ const dir = args[1] ?? slugOf(project.name)
35
+ const root = resolve(dir)
36
+ if (existsSync(root) && readdirSync(root).length > 0) throw new CliError(`Directory "${dir}" already exists and is not empty.`)
37
+ mkdirSync(root, { recursive: true })
38
+
39
+ info(c.dim(`Fetching project ${c.bold(project.name)}…`))
40
+ const files = await materializeAssets(root, project, apiUrl, token, progress)
41
+ endProgress()
42
+
43
+ if (!flags["no-types"]) {
44
+ info(c.dim("Fetching type definitions…"))
45
+ await materializeTypes(root, apiUrl)
46
+ writeTsconfig(root)
47
+ }
48
+ writeDefaultIgnore(root)
49
+ ensureGitignore(root) // .lecodes/ is per-checkout state — keep it out of the project's git repo
50
+
51
+ const commits = await getCommits(apiUrl, token, uuid)
52
+ writeManifest(root, { uuid, apiUrl, name: project.name, baseHeadId: commits.headId, files })
53
+
54
+ success(`Cloned ${c.bold(project.name)} into ${c.bold(dir)}/ (${Object.keys(files).length} files)`)
55
+ info(c.dim(` cd ${dir} — edit, then \`lecodes push -m "your message"\``))
56
+ },
57
+ })