lecodes-cli 0.18.2 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/dist/index.js +1866 -301
  2. package/package.json +14 -12
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk-types.json +1 -1
  5. package/src/api.ts +302 -0
  6. package/src/browserAuth.ts +87 -0
  7. package/src/cmgenTool.ts +104 -0
  8. package/src/commands/app.ts +891 -0
  9. package/src/commands/appAndroid.ts +603 -0
  10. package/src/commands/appDesktop.ts +326 -0
  11. package/src/commands/appDesktopMac.ts +470 -0
  12. package/src/commands/appIcon.ts +187 -0
  13. package/src/commands/appShared.ts +448 -0
  14. package/src/commands/appTemplates.ts +883 -0
  15. package/src/commands/appTemplatesAndroid.ts +599 -0
  16. package/src/commands/appTemplatesGradlew.ts +9 -0
  17. package/src/commands/assets.ts +28 -0
  18. package/src/commands/clone.ts +60 -0
  19. package/src/commands/compile.ts +253 -0
  20. package/src/commands/create.ts +59 -0
  21. package/src/commands/design.ts +863 -0
  22. package/src/commands/designTemplates.ts +11 -0
  23. package/src/commands/desktop.ts +122 -0
  24. package/src/commands/dev.ts +214 -0
  25. package/src/commands/diff.ts +59 -0
  26. package/src/commands/init.ts +191 -0
  27. package/src/commands/install.ts +198 -0
  28. package/src/commands/lightmap.ts +290 -0
  29. package/src/commands/link.ts +147 -0
  30. package/src/commands/login.ts +24 -0
  31. package/src/commands/navmesh.ts +225 -0
  32. package/src/commands/pn.ts +305 -0
  33. package/src/commands/projectTemplates.ts +129 -0
  34. package/src/commands/pull.ts +109 -0
  35. package/src/commands/push.ts +173 -0
  36. package/src/commands/render.ts +414 -0
  37. package/src/commands/scene.ts +148 -0
  38. package/src/commands/shaders.ts +157 -0
  39. package/src/commands/status.ts +29 -0
  40. package/src/commands/test.ts +370 -0
  41. package/src/commands/thumbs.ts +178 -0
  42. package/src/commands/types.ts +87 -0
  43. package/src/commands/update.ts +190 -0
  44. package/src/compile/assetIcons.ts +214 -0
  45. package/src/compile/collect.ts +46 -0
  46. package/src/compile/collectLocal.ts +37 -0
  47. package/src/compile/designCompile.ts +104 -0
  48. package/src/compile/fonts.ts +156 -0
  49. package/src/compile/headlessBundle.ts +129 -0
  50. package/src/compile/nativeStack.ts +21 -0
  51. package/src/compile/projectCompile.ts +109 -0
  52. package/src/compile/sceneCompile.ts +113 -0
  53. package/src/compile/screenEntry.ts +127 -0
  54. package/src/compile/shaders.ts +245 -0
  55. package/src/config.ts +42 -0
  56. package/src/designMeta.ts +35 -0
  57. package/src/desktopRenderer.ts +532 -0
  58. package/src/desktopScript.ts +276 -0
  59. package/src/dev/clientTemplates.ts +160 -0
  60. package/src/dev/devServer.ts +292 -0
  61. package/src/dev/wsServer.ts +144 -0
  62. package/src/distRoot.ts +20 -0
  63. package/src/ignore.ts +163 -0
  64. package/src/index.ts +491 -0
  65. package/src/lecodes-3d-editor.d.ts +41 -0
  66. package/src/lecodes-assets.d.ts +7 -0
  67. package/src/lecodes-design.d.ts +191 -0
  68. package/src/lecodes-renderer.d.ts +131 -0
  69. package/src/localFiles.ts +144 -0
  70. package/src/manifest.ts +46 -0
  71. package/src/matcTool.ts +137 -0
  72. package/src/peers.ts +40 -0
  73. package/src/project.ts +61 -0
  74. package/src/projectEnv.ts +94 -0
  75. package/src/qrcode-terminal.d.ts +9 -0
  76. package/src/releases.ts +125 -0
  77. package/src/serverDiff.ts +78 -0
  78. package/src/textDiff.ts +103 -0
  79. package/src/types.ts +0 -0
  80. package/src/util.ts +146 -0
  81. package/runtime/sdk/animate/animate.ts +0 -238
  82. package/runtime/sdk/animate/bezier.ts +0 -138
  83. package/runtime/sdk/animate/easings.ts +0 -126
  84. package/runtime/sdk/canvas/Canvas.ts +0 -305
  85. package/runtime/sdk/core/Aspect.ts +0 -512
  86. package/runtime/sdk/core/InspectorUI.ts +0 -212
  87. package/runtime/sdk/core/color.ts +0 -66
  88. package/runtime/sdk/core/compWrite.ts +0 -42
  89. package/runtime/sdk/core/events.ts +0 -38
  90. package/runtime/sdk/core/fields.ts +0 -120
  91. package/runtime/sdk/core/registry.ts +0 -23
  92. package/runtime/sdk/core/signals.ts +0 -277
  93. package/runtime/sdk/core/time.ts +0 -81
  94. package/runtime/sdk/g2/Camera2D.ts +0 -40
  95. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  96. package/runtime/sdk/g2/Node2D.ts +0 -267
  97. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  98. package/runtime/sdk/g2/Physics2D.ts +0 -346
  99. package/runtime/sdk/g2/Scene2D.ts +0 -209
  100. package/runtime/sdk/g2/Shape2D.ts +0 -259
  101. package/runtime/sdk/g2/Sprite.ts +0 -89
  102. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  103. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  104. package/runtime/sdk/g2/Texture2D.ts +0 -47
  105. package/runtime/sdk/g2/Tilemap.ts +0 -41
  106. package/runtime/sdk/g2/Tileset.ts +0 -71
  107. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  108. package/runtime/sdk/g2/autotile.ts +0 -433
  109. package/runtime/sdk/g2/cells.ts +0 -91
  110. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  111. package/runtime/sdk/g2/groups2d.ts +0 -106
  112. package/runtime/sdk/g2/loop.ts +0 -50
  113. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  114. package/runtime/sdk/g2/touch.ts +0 -83
  115. package/runtime/sdk/gl/Camera.ts +0 -160
  116. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  117. package/runtime/sdk/gl/CharacterController.ts +0 -238
  118. package/runtime/sdk/gl/Gearbox.ts +0 -212
  119. package/runtime/sdk/gl/Geometry.ts +0 -279
  120. package/runtime/sdk/gl/IK.ts +0 -193
  121. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  122. package/runtime/sdk/gl/Light.ts +0 -99
  123. package/runtime/sdk/gl/Lightmap.ts +0 -179
  124. package/runtime/sdk/gl/Material.ts +0 -245
  125. package/runtime/sdk/gl/Mesh.ts +0 -83
  126. package/runtime/sdk/gl/Model.ts +0 -64
  127. package/runtime/sdk/gl/Node.ts +0 -350
  128. package/runtime/sdk/gl/Noise.ts +0 -30
  129. package/runtime/sdk/gl/Particles.ts +0 -676
  130. package/runtime/sdk/gl/Physics.ts +0 -222
  131. package/runtime/sdk/gl/Plane.ts +0 -53
  132. package/runtime/sdk/gl/Ray.ts +0 -16
  133. package/runtime/sdk/gl/Scene.ts +0 -479
  134. package/runtime/sdk/gl/Shape.ts +0 -377
  135. package/runtime/sdk/gl/Texture.ts +0 -46
  136. package/runtime/sdk/gl/Trigger.ts +0 -45
  137. package/runtime/sdk/gl/Vehicle.ts +0 -473
  138. package/runtime/sdk/gl/Wheel.ts +0 -240
  139. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  140. package/runtime/sdk/gl/animation/Animator.ts +0 -87
  141. package/runtime/sdk/gl/animation/Layer.ts +0 -29
  142. package/runtime/sdk/gl/animation/Loop.ts +0 -25
  143. package/runtime/sdk/gl/animation/Playback.ts +0 -43
  144. package/runtime/sdk/gl/animation/core.ts +0 -294
  145. package/runtime/sdk/gl/controls.ts +0 -95
  146. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  147. package/runtime/sdk/gl/scenarios.ts +0 -291
  148. package/runtime/sdk/gl/state.ts +0 -6
  149. package/runtime/sdk/gl/touch.ts +0 -68
  150. package/runtime/sdk/inject.ts +0 -186
  151. package/runtime/sdk/math/Mathf.ts +0 -118
  152. package/runtime/sdk/math/mat4.ts +0 -278
  153. package/runtime/sdk/math/quat.ts +0 -232
  154. package/runtime/sdk/math/vec.ts +0 -255
  155. package/runtime/sdk/plugins/camera.ts +0 -81
  156. package/runtime/sdk/plugins/geolocation.ts +0 -123
  157. package/runtime/sdk/plugins/oauth.ts +0 -61
  158. package/runtime/sdk/plugins/permission.ts +0 -7
  159. package/runtime/sdk/plugins/push.ts +0 -132
  160. package/runtime/sdk/plugins/qr.ts +0 -73
  161. package/runtime/sdk/plugins/service.ts +0 -47
  162. package/runtime/sdk/runtime/app.ts +0 -101
  163. package/runtime/sdk/runtime/appEvents.ts +0 -54
  164. package/runtime/sdk/runtime/channel.ts +0 -61
  165. package/runtime/sdk/runtime/clipboard.ts +0 -20
  166. package/runtime/sdk/runtime/datetime.ts +0 -329
  167. package/runtime/sdk/runtime/device.ts +0 -293
  168. package/runtime/sdk/runtime/fetch.ts +0 -77
  169. package/runtime/sdk/runtime/files.ts +0 -23
  170. package/runtime/sdk/runtime/input.ts +0 -175
  171. package/runtime/sdk/runtime/media.ts +0 -111
  172. package/runtime/sdk/runtime/misc.ts +0 -16
  173. package/runtime/sdk/runtime/net.ts +0 -36
  174. package/runtime/sdk/runtime/rpc.ts +0 -218
  175. package/runtime/sdk/runtime/service.ts +0 -83
  176. package/runtime/sdk/runtime/share.ts +0 -9
  177. package/runtime/sdk/runtime/storage.ts +0 -13
  178. package/runtime/sdk/runtime/touch.ts +0 -76
  179. package/runtime/sdk/scene/defineScene.ts +0 -1227
  180. package/runtime/sdk/scene/editorPlugins.ts +0 -92
  181. package/runtime/sdk/scene/gizmos.ts +0 -148
  182. package/runtime/sdk/scene/grammar.ts +0 -120
  183. package/runtime/sdk/scene/material.ts +0 -188
  184. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  185. package/runtime/sdk/server/auth/global.ts +0 -80
  186. package/runtime/sdk/server/auth/host.ts +0 -318
  187. package/runtime/sdk/server/auth/models.ts +0 -83
  188. package/runtime/sdk/server/auth/types.ts +0 -50
  189. package/runtime/sdk/server/channel.ts +0 -56
  190. package/runtime/sdk/server/context.ts +0 -36
  191. package/runtime/sdk/server/db/defineDb.ts +0 -237
  192. package/runtime/sdk/server/db/fields.ts +0 -132
  193. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  194. package/runtime/sdk/server/db/index.ts +0 -7
  195. package/runtime/sdk/server/db/marci/query.ts +0 -412
  196. package/runtime/sdk/server/db/types.ts +0 -202
  197. package/runtime/sdk/server/errors.ts +0 -12
  198. package/runtime/sdk/server/host.ts +0 -74
  199. package/runtime/sdk/server/inject.ts +0 -13
  200. package/runtime/sdk/server/runtime.ts +0 -133
  201. package/runtime/sdk/server/validate.ts +0 -87
  202. package/runtime/sdk/ui/NativeView.ts +0 -142
  203. package/runtime/sdk/ui/UI.ts +0 -39
  204. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  205. package/runtime/sdk/ui/UIButton.ts +0 -101
  206. package/runtime/sdk/ui/UIContainer.ts +0 -60
  207. package/runtime/sdk/ui/UIImage.ts +0 -83
  208. package/runtime/sdk/ui/UIInput.ts +0 -185
  209. package/runtime/sdk/ui/UIModal.ts +0 -139
  210. package/runtime/sdk/ui/UINode.ts +0 -826
  211. package/runtime/sdk/ui/UIPager.ts +0 -362
  212. package/runtime/sdk/ui/UIPopover.ts +0 -100
  213. package/runtime/sdk/ui/UIScreen.ts +0 -123
  214. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  215. package/runtime/sdk/ui/UISpacer.ts +0 -14
  216. package/runtime/sdk/ui/UITabs.ts +0 -236
  217. package/runtime/sdk/ui/UIText.ts +0 -51
  218. package/runtime/sdk/ui/UIVideo.ts +0 -88
  219. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  220. package/runtime/sdk/ui/UIWidget.ts +0 -127
  221. package/runtime/sdk/ui/fonts.ts +0 -13
  222. package/runtime/sdk/ui/presentable.ts +0 -117
  223. package/runtime/sdk/ui/router.ts +0 -132
  224. package/runtime/sdk/ui/theme.ts +0 -84
@@ -0,0 +1,448 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
+ import { homedir } from "node:os"
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"
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. */
13
+
14
+ /** First-party plugin registry: id → GitHub repo holding plugins/<id>/. Third-party plugins
15
+ * use a `github:owner/repo[@tag]` spec in app.json instead (manifest at the repo root). */
16
+ export const FIRST_PARTY_PLUGINS: Record<string, string> = {
17
+ "camera": "letary/lecodes-plugins",
18
+ "qr-scanner": "letary/lecodes-plugins",
19
+ "geolocation": "letary/lecodes-plugins",
20
+ "push": "letary/lecodes-plugins",
21
+ }
22
+
23
+ export interface AppConfig {
24
+ name?: string
25
+ version?: string
26
+ /** Project-root-relative path of a square ≥1024px PNG/JPEG master. When set, every sync
27
+ * renders the platform icons from it (iOS AppIcon + Android adaptive/legacy mipmaps) and
28
+ * those files become CLI-owned like assets/. Absent = icons stay hand-managed; removing
29
+ * the key stops regeneration but deletes nothing. */
30
+ icon?: string
31
+ /** Icon backdrop ("#RGB"/"#RRGGBB", default white): the Android adaptive-icon background
32
+ * layer, and what a transparent master is flattened onto for the opacity-required iOS icon. */
33
+ iconBackground?: string
34
+ plugins?: Record<string, string>
35
+ /** `remote`: lecodesignore-style globs of resources to STREAM from the server instead of
36
+ * embedding in the app (server-backed projects only — a streamed asset needs a server URL). */
37
+ resources?: { remote?: string[] }
38
+ /** Over-the-air JS updates: where the shell checks for newer compiled code at launch
39
+ * (natively, before the JS engine — offline is a no-op, new code applies next launch).
40
+ * `true` = this project's live bundle on the platform (server-backed only); or an https
41
+ * URL to a compiled bundle on any hosting (string or `{ "url": … }`). Absent = no OTA.
42
+ * iOS-only for now — the Android runtime has no native updater yet. */
43
+ update?: boolean | string | { url?: string }
44
+ /** `variant`: SDK product — "auto" (default: sync picks the smallest one covering the engines
45
+ * the compiled bundle actually uses) or core|2d|3d|full to force one.
46
+ * `sdk`: native SDK version — an exact lecodes-ios-sdk release (init pins the newest one,
47
+ * `lecodes app update` moves it), or "latest" to re-resolve on every sync. */
48
+ ios?: { bundleId?: string, teamId?: string, variant?: string, buildNumber?: number, sdk?: string }
49
+ /** `applicationId`: the Android package name (init default: com.example.<slug>).
50
+ * `versionCode`: Play's monotonically increasing build number (versionName comes from the
51
+ * shared `version`). `sdk`: an exact lecodes-android-sdk release or "latest", like ios.sdk.
52
+ * `variant`: SDK artifact — "auto" (default: sync picks the smallest one covering the engines
53
+ * the compiled bundle actually uses) or core|2d|3d|full to force one, like ios.variant
54
+ * (needs lecodes-android-sdk >= 1.3.0 — older releases ship only the full AAR).
55
+ * `signing`: keystore METADATA only — the store/key passwords live in android/
56
+ * keystore.properties (gitignored) or LECODES_ANDROID_* env vars, never in app.json. */
57
+ android?: {
58
+ applicationId?: string, versionCode?: number, sdk?: string, variant?: string,
59
+ signing?: { storeFile?: string, keyAlias?: string },
60
+ }
61
+ /** Desktop host (packages/desktop). `window` sizes are LOGICAL units — the host multiplies by
62
+ * the display scale. `entry` picks a per-target entry file (default: the detected project
63
+ * entry) — see docs/desktop-target-plan.md. */
64
+ desktop?: {
65
+ entry?: string,
66
+ window?: DesktopWindowConfig,
67
+ /** Which desktop host binary to run: "gl" (default) or "vulkan" (Windows only, host >= 1.4.0).
68
+ * A BUILD/DEV-TIME host selection, not a runtime switch — the two are separate binaries with
69
+ * their own shaders and different features (the Vulkan one has no 2D engine and no native-view
70
+ * plugins). Overridable per run with `--renderer` / LECODES_DESKTOP_RENDERER. */
71
+ renderer?: "gl" | "vulkan",
72
+ /** 3D buffer scale vs the window (0.25–1, host ≥1.4.0). The UI always renders at native
73
+ * pixels; only the 3D scene is rendered smaller and upscaled by the compositor. Multiplies
74
+ * the scene's own `renderScale` (SceneOptions). Headless render/test ignore it. */
75
+ renderScale?: number,
76
+ /** 3D buffer cap in px, e.g. [1920, 1080]: on a bigger window/monitor the 3D renders at most
77
+ * this size (aspect kept) — the per-machine answer to "fullscreen on a 4K display is slow". */
78
+ maxRenderSize?: [number, number],
79
+ /** The macOS .app build (`lecodes app build desktop` on a Mac) — identity + signing +
80
+ * notarization. See appDesktopMac.ts for the whole story. */
81
+ macos?: MacDesktopConfig,
82
+ }
83
+ }
84
+
85
+ /** app.json `desktop.macos`. Only NAMES live here (identity, keychain profile) — the certificate
86
+ * and the notarization password stay in the keychain. */
87
+ export interface MacDesktopConfig {
88
+ /** CFBundleIdentifier (default codes.le.app.<slug>). Reverse-DNS, stable across releases. */
89
+ bundleId?: string
90
+ /** CFBundleVersion (default 1). Bump per upload; the top-level `version` is the marketing one. */
91
+ buildNumber?: number
92
+ /** LSApplicationCategoryType, e.g. "public.app-category.games". */
93
+ category?: string
94
+ /** `identity`: a "Developer ID Application: Name (TEAM)" codesign identity, or "adhoc" (local
95
+ * only — refused on every other Mac). Absent/"auto": the keychain's Developer ID cert, else
96
+ * ad-hoc. Overridden by --sign / LECODES_MACOS_SIGN_ID. */
97
+ signing?: { identity?: string }
98
+ /** `profile`: a `xcrun notarytool store-credentials <name>` keychain profile. With it the build
99
+ * is notarized + stapled (needs a Developer ID identity). Overridden by --notarize /
100
+ * LECODES_NOTARY_PROFILE; --no-notarize skips for a local build. */
101
+ notarize?: { profile?: string }
102
+ }
103
+
104
+ export interface DesktopWindowConfig {
105
+ /** Initial client size, logical units (host default: 1280×720). */
106
+ width?: number
107
+ height?: number
108
+ minWidth?: number
109
+ minHeight?: number
110
+ /** false → fixed-size window (default: resizable). */
111
+ resizable?: boolean
112
+ /** Window title (default: the top-level `name`). */
113
+ title?: string
114
+ /** Borderless fullscreen on the primary monitor (kiosks). `width`/`height` stay the
115
+ * design/dev frame; headless render/test always run windowed (the render contract). */
116
+ fullscreen?: boolean
117
+ /** Display scale (host ≥1.3.0). "system" (default): the OS display scale. A number (0.5–4):
118
+ * a fixed density — the window opens at `width×scale` physical px. "fit": derived at launch
119
+ * from the design frame vs the monitor (fullscreen) / work area, so a 1080p-designed app
120
+ * runs density-2 on a 4K display and scales DOWN on smaller screens. Headless render/test
121
+ * always force scale 1 (the render contract). */
122
+ scale?: number | "fit" | "system"
123
+ }
124
+
125
+ /** app.json wherever a command needs the project-level desktop/window config. Tolerant of a
126
+ * missing or broken file — standalone projects (no app.json) must keep working everywhere
127
+ * this is consulted. */
128
+ export const readAppConfigAt = (root: string): AppConfig | null =>
129
+ readJson<AppConfig>(join(root, "app.json"))
130
+
131
+ /** The desktop host's own built-in fallback, mirrored where the CLI must fill a missing side. */
132
+ export const DESKTOP_DEFAULT_WINDOW = { width: 1280, height: 720 }
133
+
134
+ /**
135
+ * The renderer for this run: `--renderer` > LECODES_DESKTOP_RENDERER > app.json `desktop.renderer`
136
+ * > "gl". Resolve it ONCE per command and pass the result to both `resolveDesktopExe` and the
137
+ * shader staging — two independent reads of the config is how a build ends up with a Vulkan exe
138
+ * next to an OpenGL .filamat, which fails at the first material with a Filament abort.
139
+ */
140
+ export const resolveDesktopRenderer = (root: string, args?: Args): "gl" | "vulkan" => {
141
+ const raw = (args ? flagStr(args, "renderer") : undefined)
142
+ ?? process.env.LECODES_DESKTOP_RENDERER
143
+ ?? readAppConfigAt(root)?.desktop?.renderer
144
+ if (raw === undefined) return "gl"
145
+ const value = String(raw).toLowerCase()
146
+ if (value === "gl" || value === "opengl") return "gl"
147
+ if (value === "vulkan" || value === "vk") {
148
+ // The macOS host is Metal, full stop. A project tuned for the Windows Vulkan host must still
149
+ // build/run on a Mac, so the setting is a no-op here rather than an error.
150
+ if (process.platform === "darwin") { note("desktop.renderer \"vulkan\" is Windows-only — the macOS host renders with Metal."); return "gl" }
151
+ return "vulkan"
152
+ }
153
+ throw new CliError(`Unknown desktop renderer "${raw}". Use "gl" or "vulkan".`)
154
+ }
155
+
156
+ /** Project-level default viewport for HEADLESS render/test: the desktop window when the project
157
+ * declares desktop as its ONLY native target (a desktop-only project gets desktop-sized frames
158
+ * and flow tests for free). Mixed or mobile projects keep the phone default — headless frames
159
+ * exercise the phone UI there. */
160
+ export const desktopDefaultViewport = (root: string): { width: number, height: number } | null => {
161
+ const cfg = readAppConfigAt(root)
162
+ if (!cfg?.desktop || cfg.ios || cfg.android) return null
163
+ return {
164
+ width: cfg.desktop.window?.width ?? DESKTOP_DEFAULT_WINDOW.width,
165
+ height: cfg.desktop.window?.height ?? DESKTOP_DEFAULT_WINDOW.height,
166
+ }
167
+ }
168
+
169
+ export interface PluginManifest {
170
+ id: string
171
+ version?: string
172
+ provides?: { views?: string[], services?: string[] }
173
+ ios?: {
174
+ sources?: string, module?: string, register?: string, minSdk?: string,
175
+ infoPlist?: Record<string, string>,
176
+ /** Entitlements the plugin's capability needs (plist-typed JSON values, arrays allowed —
177
+ * e.g. push's `{"aps-environment": "development"}`). Merged into App/App.entitlements
178
+ * by sync; the relay signs sends for Letary-published bundle ids with the shared team
179
+ * key, and the register route fail-fasts for anything outside that namespace. */
180
+ entitlements?: Record<string, unknown>,
181
+ } | null
182
+ android?: {
183
+ /** Dir vendored into the shell (default "android") — a Gradle-module-shaped tree minus
184
+ * build.gradle (sync generates that): src/main/AndroidManifest.xml carries the plugin's
185
+ * permissions/services (AGP's manifest merger is the infoPlist analog), libs/*.aar become
186
+ * local file deps, consumer-rules.pro becomes the module's consumer ProGuard rules. */
187
+ sources?: string,
188
+ /** Gradle module name (kebab-case) → :plugins:<module> in the shell. */
189
+ module?: string,
190
+ /** Fully-qualified Kotlin object with `fun register(engine: LecodesEngine, context: Context)`;
191
+ * its package doubles as the generated module's AGP namespace. */
192
+ register?: string,
193
+ /** Minimum lecodes-android-sdk release (NOT Android's minSdkVersion — the OS floor comes
194
+ * from the SDK itself). Sync hard-errors below it, like ios.minSdk. */
195
+ minSdk?: string,
196
+ /** Maven coordinates for the generated build.gradle. A "platform:" prefix maps to
197
+ * implementation(platform(…)) — BoMs; versionless coords are only valid under one. */
198
+ dependencies?: string[],
199
+ /** App-level Gradle plugin ids the shell must apply (e.g. com.google.gms.google-services).
200
+ * Bare ids resolve through the CLI's known-version table; "id:version" pins explicitly. */
201
+ gradlePlugins?: string[],
202
+ } | null
203
+ }
204
+
205
+ export interface LockEntry { repo: string, tag: string, commit: string, version: string }
206
+
207
+ export const readJson = <T>(path: string): T | null => {
208
+ if (!existsSync(path)) return null
209
+ // BOM-tolerant: Notepad and PowerShell write UTF-8 with a BOM, and app.json is hand-edited.
210
+ try { return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")) as T } catch {
211
+ throw new CliError(`Invalid JSON in ${path}`)
212
+ }
213
+ }
214
+
215
+ export const writeJson = (path: string, value: unknown) =>
216
+ writeFileSync(path, JSON.stringify(value, null, 2) + "\n")
217
+
218
+ export const projectRoot = (): string => {
219
+ try { return findProjectRoot(process.cwd()) } catch { return process.cwd() }
220
+ }
221
+
222
+ export const git = (cwd: string, ...args: string[]): string =>
223
+ execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
224
+
225
+ export const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "app"
226
+
227
+ /** Run a tool, capturing output; on failure surface the tail (xcodebuild/gradle logs are huge). */
228
+ export const runTool = (cwd: string, cmd: string, args: string[]): string => {
229
+ try {
230
+ return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 128 * 1024 * 1024 })
231
+ } catch (e) {
232
+ const err = e as { stdout?: string, stderr?: string }
233
+ const tail = ((err.stdout ?? "") + "\n" + (err.stderr ?? "")).split("\n").filter(l => l.trim()).slice(-25).join("\n")
234
+ throw new CliError(`${cmd} ${args[0] ?? ""} failed:\n${tail}`)
235
+ }
236
+ }
237
+
238
+ /** Add a shell dir (ios/, android/) to .lecodesignore — shells are never pushed to LeCodes. */
239
+ export const ensureShellIgnored = (root: string, dir: string) => {
240
+ const ignorePath = join(root, ".lecodesignore")
241
+ const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : DEFAULT_IGNORE_FILE
242
+ if (new RegExp(`^${dir}\\/$`, "m").test(ignore)) return
243
+ const comment = ignore.includes("# Native app shells")
244
+ ? ""
245
+ : "# Native app shells — generated by `lecodes app init`, never pushed to LeCodes.\n"
246
+ writeFileSync(ignorePath, ignore.replace(/\n*$/, "\n") + "\n" + comment + `${dir}/\n`)
247
+ log(`${c.green("updated")} .lecodesignore (+ ${dir}/)`)
248
+ }
249
+
250
+ /* ----------------------------------- SDK variants ----------------------------------- */
251
+
252
+ // Both native SDKs ship the same variant matrix (full / 3d / 2d / core), so the detection and
253
+ // resolution logic is shared between the iOS (app.ts) and Android (appAndroid.ts) syncs.
254
+ export const SDK_VARIANTS = ["core", "2d", "3d", "full"] as const
255
+ type SdkVariantShared = (typeof SDK_VARIANTS)[number]
256
+
257
+ /** Engine signals from a compiled bundle's header — the same lines buildHeader emits.
258
+ * No `// gl: disable` line → the 3D engine is used (eagerly, or lazily via `gl: defer`);
259
+ * `// 2d: enable` → the 2D engine is used. */
260
+ export const detectEngines = (bundleJs: string): { gl: boolean, g2: boolean } => {
261
+ const lines: string[] = []
262
+ for (const line of bundleJs.split("\n")) {
263
+ if (!line.startsWith("//")) break
264
+ lines.push(line)
265
+ }
266
+ const header = lines.join("\n")
267
+ return { gl: !/^\/\/ gl: disable$/m.test(header), g2: /^\/\/ 2d: enable$/m.test(header) }
268
+ }
269
+
270
+ /**
271
+ * Refuse a Vulkan desktop build for a project that uses the 2D engine. The Vulkan host is built with
272
+ * CREATOR_PKG_2D=OFF (creator-2d is sokol-on-GL, with no Vulkan twin), so those scenes don't render
273
+ * — a blank window at runtime with nothing in the log to explain it. Fail at build time instead,
274
+ * where the fix ("use the default renderer") is one line away.
275
+ *
276
+ * Detection is the same `// 2d: enable` bundle header `lecodes app sync` picks its SDK variant from,
277
+ * so it can't drift from what the engine actually needs.
278
+ */
279
+ export const assertRendererSupportsBundle = (renderer: "gl" | "vulkan", bundleJs: string): void => {
280
+ if (renderer !== "vulkan") return
281
+ if (!detectEngines(bundleJs).g2) return
282
+ throw new CliError(
283
+ "This project uses the 2D engine, which the Vulkan desktop host doesn't include " +
284
+ "(creator-2d renders through OpenGL and has no Vulkan path yet).\n" +
285
+ " Build with the default renderer: drop `desktop.renderer` from app.json, or pass `--renderer gl`.",
286
+ )
287
+ }
288
+
289
+ /** The SDK product for a shell: the smallest variant covering the engines the compiled bundle
290
+ * actually uses, unless <configKey> (ios.variant / android.variant) forces one — then warn when
291
+ * the forced product can't run the project (missing engine = those scenes silently no-op on
292
+ * device). */
293
+ export const resolveVariant = (requested: string, bundleJs: string, configKey = "ios.variant"): { variant: SdkVariantShared, detected: SdkVariantShared } => {
294
+ const uses = detectEngines(bundleJs)
295
+ const detected: SdkVariantShared = uses.gl && uses.g2 ? "full" : uses.gl ? "3d" : uses.g2 ? "2d" : "core"
296
+ if (requested === "auto") return { variant: detected, detected }
297
+ const variant = requested as SdkVariantShared
298
+ const missing = [
299
+ uses.gl && variant !== "3d" && variant !== "full" ? "3D" : null,
300
+ uses.g2 && variant !== "2d" && variant !== "full" ? "2D" : null,
301
+ ].filter(Boolean)
302
+ if (missing.length > 0) {
303
+ warn(`project uses the ${missing.join(" + ")} engine but ${configKey} forces "${variant}" — those scenes won't run on device; remove ${configKey} to auto-select "${detected}".`)
304
+ }
305
+ return { variant, detected }
306
+ }
307
+
308
+ /* ----------------------------------- SDK version ------------------------------------ */
309
+
310
+ export const VERSION_RE = /^\d+\.\d+\.\d+$/
311
+
312
+ /** Numeric semver-ish compare (only x.y.z tags are published). */
313
+ export const cmpVersion = (a: string, b: string): number => {
314
+ const pa = a.split(".").map(Number)
315
+ const pb = b.split(".").map(Number)
316
+ for (let i = 0; i < 3; i++) {
317
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0)
318
+ }
319
+ return 0
320
+ }
321
+
322
+ /** The newest published release of an SDK dist repo (prereleases excluded). Network — throws. */
323
+ export const fetchLatestSdkVersion = async (repo: string): Promise<string> => {
324
+ const url = `https://api.github.com/repos/${repo}/releases/latest`
325
+ const res = await fetch(url, { headers: { "accept": "application/vnd.github+json", "user-agent": "lecodes-cli" } })
326
+ if (!res.ok) throw new CliError(`Could not read the latest ${repo} release (HTTP ${res.status}) — ${url}`)
327
+ const tag = ((await res.json()) as { tag_name?: string }).tag_name?.replace(/^v/, "") ?? ""
328
+ if (!VERSION_RE.test(tag)) throw new CliError(`${repo} latest release has no x.y.z tag (got "${tag}")`)
329
+ return tag
330
+ }
331
+
332
+ /** Resolve an app.json sdk value ("latest" or an exact release) to the concrete release the
333
+ * shell links; `fallback` is what "latest" degrades to when the network is unavailable. */
334
+ export const resolveSdkVersion = async (
335
+ pin: string, fallback: string | null,
336
+ opts: { repo: string, minVersion: string, configKey: string },
337
+ ): Promise<string> => {
338
+ if (pin !== "latest") {
339
+ if (!VERSION_RE.test(pin)) {
340
+ throw new CliError(`app.json ${opts.configKey} must be "latest" or an exact x.y.z release (got "${pin}").`)
341
+ }
342
+ if (cmpVersion(pin, opts.minVersion) < 0) {
343
+ throw new CliError(`app.json ${opts.configKey} ${pin} is older than the ${opts.minVersion} these generated shells need.`)
344
+ }
345
+ return pin
346
+ }
347
+ try {
348
+ return await fetchLatestSdkVersion(opts.repo)
349
+ } catch (e) {
350
+ const kept = fallback ?? opts.minVersion
351
+ warn(`Could not resolve the latest SDK (${(e as Error).message}) — staying on ${kept}.`)
352
+ return kept
353
+ }
354
+ }
355
+
356
+ /* ---------------------------------- plugin vendoring ---------------------------------- */
357
+
358
+ export interface ResolvedPlugin {
359
+ id: string
360
+ dir: string // directory containing lecodes-plugin.json
361
+ manifest: PluginManifest
362
+ lock: LockEntry
363
+ }
364
+
365
+ /** Clone-or-update the plugin repo cache and check out the wanted commit (detached).
366
+ * `LECODES_PLUGINS_DIR` overrides with a local working tree — the plugin-development loop. */
367
+ export const ensureRepo = (repo: string, wantTag: string | null, lock: LockEntry | undefined): { dir: string, tag: string, commit: string } => {
368
+ const override = process.env.LECODES_PLUGINS_DIR
369
+ if (override) {
370
+ note(`Using local plugin checkout ${override} (LECODES_PLUGINS_DIR)`)
371
+ return { dir: resolve(override), tag: "local", commit: "local" }
372
+ }
373
+
374
+ const cache = join(homedir(), ".lecodes", "plugins", repo.replace("/", "__"))
375
+ const url = `https://github.com/${repo}.git`
376
+ if (!existsSync(join(cache, ".git"))) {
377
+ mkdirSync(dirname(cache), { recursive: true })
378
+ note(`Cloning ${url}…`)
379
+ git(dirname(cache), "clone", "--quiet", url, cache)
380
+ }
381
+
382
+ // A lock pin wins; else an explicit @tag; else the latest v* tag (fetched fresh).
383
+ let tag = lock?.tag ?? wantTag
384
+ let commit = lock?.commit
385
+ if (commit) {
386
+ try { git(cache, "cat-file", "-e", `${commit}^{commit}`) } catch {
387
+ git(cache, "fetch", "--quiet", "--tags", "origin")
388
+ }
389
+ } else {
390
+ git(cache, "fetch", "--quiet", "--tags", "origin")
391
+ if (!tag) {
392
+ tag = git(cache, "tag", "--list", "v*", "--sort=-version:refname").split("\n")[0] ?? ""
393
+ if (!tag) throw new CliError(`${repo} has no v* version tags to install from`)
394
+ }
395
+ commit = git(cache, "rev-list", "-n", "1", tag)
396
+ }
397
+ git(cache, "checkout", "--quiet", "--detach", commit)
398
+ return { dir: cache, tag: tag ?? "unknown", commit }
399
+ }
400
+
401
+ /** Resolve one app.json plugin entry to vendorable sources + manifest + lock pin. The platform
402
+ * being synced must be supported (its manifest branch non-null) — a hard error otherwise. */
403
+ export const resolvePlugin = (id: string, spec: string, lock: LockEntry | undefined, platform: "ios" | "android"): ResolvedPlugin => {
404
+ let repo: string
405
+ let subdir: string
406
+ let wantTag: string | null = null
407
+
408
+ if (spec.startsWith("github:")) {
409
+ const [repoPart, tagPart] = spec.slice("github:".length).split("@")
410
+ repo = repoPart
411
+ wantTag = tagPart ?? null
412
+ subdir = "" // third-party: manifest at the repo root
413
+ } else {
414
+ repo = FIRST_PARTY_PLUGINS[id]
415
+ if (!repo) {
416
+ throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use a github:owner/repo spec.`)
417
+ }
418
+ subdir = `plugins/${id}`
419
+ }
420
+
421
+ const checkout = ensureRepo(repo, wantTag, lock?.repo === repo ? lock : undefined)
422
+ const dir = subdir ? join(checkout.dir, subdir) : checkout.dir
423
+ const manifest = readJson<PluginManifest>(join(dir, "lecodes-plugin.json"))
424
+ if (!manifest) throw new CliError(`Plugin "${id}": no lecodes-plugin.json in ${dir}`)
425
+ if (manifest.id !== id) throw new CliError(`Plugin "${id}": manifest declares id "${manifest.id}"`)
426
+ if (!manifest[platform]) {
427
+ throw new CliError(`Plugin "${id}" does not support ${platform === "ios" ? "iOS" : "Android"} (manifest "${platform}" is null)`)
428
+ }
429
+
430
+ // Namespacing: third-party channel names must be vendor-prefixed ("acme.thing").
431
+ if (spec.startsWith("github:")) {
432
+ const names = [...(manifest.provides?.views ?? []), ...(manifest.provides?.services ?? [])]
433
+ for (const name of names) {
434
+ if (!name.includes(".")) warn(`Plugin "${id}" provides bare channel name "${name}" — third-party names should be vendor-prefixed (e.g. "acme.${name}").`)
435
+ }
436
+ }
437
+
438
+ return {
439
+ id, dir, manifest,
440
+ lock: { repo, tag: checkout.tag, commit: checkout.commit, version: manifest.version ?? "unknown" },
441
+ }
442
+ }
443
+
444
+ /** Newest v* tag of a plugin repo, via the shared plugin cache. Null when it can't be read
445
+ * (no network, no tags, local override) — `app update` reports it and moves on. */
446
+ export const latestPluginTag = (repo: string): string | null => {
447
+ try { return ensureRepo(repo, null, undefined).tag } catch { return null }
448
+ }