lecodes-cli 0.19.0 → 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 (254) hide show
  1. package/dist/index.js +730 -235
  2. package/package.json +14 -13
  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/compile/aspectMacro.ts +0 -86
  86. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  87. package/runtime/sdk/compile/assetMacro.ts +0 -146
  88. package/runtime/sdk/compile/assetName.ts +0 -50
  89. package/runtime/sdk/compile/bundler.ts +0 -287
  90. package/runtime/sdk/compile/compileProject.ts +0 -129
  91. package/runtime/sdk/compile/detectEntry.ts +0 -128
  92. package/runtime/sdk/compile/fontMacro.ts +0 -459
  93. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  94. package/runtime/sdk/compile/header.ts +0 -67
  95. package/runtime/sdk/compile/index.ts +0 -100
  96. package/runtime/sdk/compile/libraryImports.ts +0 -52
  97. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  98. package/runtime/sdk/compile/sceneEditor.ts +0 -88
  99. package/runtime/sdk/compile/serverSplit.ts +0 -233
  100. package/runtime/sdk/compile/serverTypes.ts +0 -227
  101. package/runtime/sdk/compile/sfnt.ts +0 -98
  102. package/runtime/sdk/compile/shaderSchema.ts +0 -202
  103. package/runtime/sdk/compile/shaderTargets.ts +0 -81
  104. package/runtime/sdk/compile/sourcemap.ts +0 -25
  105. package/runtime/sdk/core/Aspect.ts +0 -568
  106. package/runtime/sdk/core/InspectorUI.ts +0 -212
  107. package/runtime/sdk/core/StateMachine.ts +0 -308
  108. package/runtime/sdk/core/color.ts +0 -66
  109. package/runtime/sdk/core/compWrite.ts +0 -42
  110. package/runtime/sdk/core/events.ts +0 -38
  111. package/runtime/sdk/core/fields.ts +0 -120
  112. package/runtime/sdk/core/registry.ts +0 -23
  113. package/runtime/sdk/core/signals.ts +0 -277
  114. package/runtime/sdk/core/time.ts +0 -84
  115. package/runtime/sdk/g2/Camera2D.ts +0 -40
  116. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  117. package/runtime/sdk/g2/Node2D.ts +0 -267
  118. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  119. package/runtime/sdk/g2/Physics2D.ts +0 -346
  120. package/runtime/sdk/g2/Scene2D.ts +0 -209
  121. package/runtime/sdk/g2/Shape2D.ts +0 -259
  122. package/runtime/sdk/g2/Sprite.ts +0 -89
  123. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  124. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  125. package/runtime/sdk/g2/Texture2D.ts +0 -47
  126. package/runtime/sdk/g2/Tilemap.ts +0 -41
  127. package/runtime/sdk/g2/Tileset.ts +0 -71
  128. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  129. package/runtime/sdk/g2/autotile.ts +0 -433
  130. package/runtime/sdk/g2/cells.ts +0 -91
  131. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  132. package/runtime/sdk/g2/groups2d.ts +0 -106
  133. package/runtime/sdk/g2/loop.ts +0 -50
  134. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  135. package/runtime/sdk/g2/touch.ts +0 -83
  136. package/runtime/sdk/gl/Camera.ts +0 -160
  137. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  138. package/runtime/sdk/gl/CharacterController.ts +0 -238
  139. package/runtime/sdk/gl/Gearbox.ts +0 -212
  140. package/runtime/sdk/gl/Geometry.ts +0 -279
  141. package/runtime/sdk/gl/IK.ts +0 -193
  142. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  143. package/runtime/sdk/gl/Light.ts +0 -107
  144. package/runtime/sdk/gl/Lightmap.ts +0 -256
  145. package/runtime/sdk/gl/Material.ts +0 -260
  146. package/runtime/sdk/gl/Mesh.ts +0 -83
  147. package/runtime/sdk/gl/Model.ts +0 -124
  148. package/runtime/sdk/gl/NavAgent.ts +0 -337
  149. package/runtime/sdk/gl/NavMesh.ts +0 -397
  150. package/runtime/sdk/gl/Node.ts +0 -350
  151. package/runtime/sdk/gl/Noise.ts +0 -30
  152. package/runtime/sdk/gl/Particles.ts +0 -676
  153. package/runtime/sdk/gl/Physics.ts +0 -222
  154. package/runtime/sdk/gl/Plane.ts +0 -53
  155. package/runtime/sdk/gl/Ragdoll.ts +0 -270
  156. package/runtime/sdk/gl/Ray.ts +0 -16
  157. package/runtime/sdk/gl/Scene.ts +0 -486
  158. package/runtime/sdk/gl/Shape.ts +0 -409
  159. package/runtime/sdk/gl/Terrain.ts +0 -1092
  160. package/runtime/sdk/gl/Texture.ts +0 -63
  161. package/runtime/sdk/gl/Trigger.ts +0 -45
  162. package/runtime/sdk/gl/Vehicle.ts +0 -473
  163. package/runtime/sdk/gl/Wheel.ts +0 -240
  164. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  165. package/runtime/sdk/gl/animation/Animator.ts +0 -120
  166. package/runtime/sdk/gl/animation/Layer.ts +0 -31
  167. package/runtime/sdk/gl/animation/Loop.ts +0 -30
  168. package/runtime/sdk/gl/animation/Playback.ts +0 -44
  169. package/runtime/sdk/gl/animation/core.ts +0 -336
  170. package/runtime/sdk/gl/controls.ts +0 -95
  171. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  172. package/runtime/sdk/gl/scenarios.ts +0 -291
  173. package/runtime/sdk/gl/state.ts +0 -6
  174. package/runtime/sdk/gl/terrainMesh.ts +0 -219
  175. package/runtime/sdk/gl/touch.ts +0 -68
  176. package/runtime/sdk/inject.ts +0 -197
  177. package/runtime/sdk/math/Mathf.ts +0 -118
  178. package/runtime/sdk/math/mat4.ts +0 -278
  179. package/runtime/sdk/math/quat.ts +0 -232
  180. package/runtime/sdk/math/vec.ts +0 -255
  181. package/runtime/sdk/net/codec.ts +0 -119
  182. package/runtime/sdk/net/core.ts +0 -384
  183. package/runtime/sdk/net/index.ts +0 -181
  184. package/runtime/sdk/net/replication.ts +0 -622
  185. package/runtime/sdk/plugins/camera.ts +0 -81
  186. package/runtime/sdk/plugins/geolocation.ts +0 -123
  187. package/runtime/sdk/plugins/oauth.ts +0 -61
  188. package/runtime/sdk/plugins/permission.ts +0 -7
  189. package/runtime/sdk/plugins/push.ts +0 -132
  190. package/runtime/sdk/plugins/qr.ts +0 -73
  191. package/runtime/sdk/plugins/service.ts +0 -47
  192. package/runtime/sdk/runtime/app.ts +0 -101
  193. package/runtime/sdk/runtime/appEvents.ts +0 -54
  194. package/runtime/sdk/runtime/channel.ts +0 -61
  195. package/runtime/sdk/runtime/clipboard.ts +0 -20
  196. package/runtime/sdk/runtime/datetime.ts +0 -329
  197. package/runtime/sdk/runtime/device.ts +0 -293
  198. package/runtime/sdk/runtime/fetch.ts +0 -77
  199. package/runtime/sdk/runtime/files.ts +0 -23
  200. package/runtime/sdk/runtime/input.ts +0 -175
  201. package/runtime/sdk/runtime/media.ts +0 -111
  202. package/runtime/sdk/runtime/misc.ts +0 -16
  203. package/runtime/sdk/runtime/net.ts +0 -36
  204. package/runtime/sdk/runtime/rpc.ts +0 -218
  205. package/runtime/sdk/runtime/service.ts +0 -83
  206. package/runtime/sdk/runtime/share.ts +0 -9
  207. package/runtime/sdk/runtime/storage.ts +0 -13
  208. package/runtime/sdk/runtime/touch.ts +0 -76
  209. package/runtime/sdk/scene/defineScene.ts +0 -1353
  210. package/runtime/sdk/scene/editorPlugins.ts +0 -110
  211. package/runtime/sdk/scene/gizmos.ts +0 -148
  212. package/runtime/sdk/scene/grammar.ts +0 -120
  213. package/runtime/sdk/scene/material.ts +0 -188
  214. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  215. package/runtime/sdk/server/auth/global.ts +0 -80
  216. package/runtime/sdk/server/auth/host.ts +0 -318
  217. package/runtime/sdk/server/auth/models.ts +0 -83
  218. package/runtime/sdk/server/auth/types.ts +0 -50
  219. package/runtime/sdk/server/channel.ts +0 -56
  220. package/runtime/sdk/server/context.ts +0 -36
  221. package/runtime/sdk/server/db/defineDb.ts +0 -237
  222. package/runtime/sdk/server/db/fields.ts +0 -132
  223. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  224. package/runtime/sdk/server/db/index.ts +0 -7
  225. package/runtime/sdk/server/db/marci/query.ts +0 -412
  226. package/runtime/sdk/server/db/types.ts +0 -202
  227. package/runtime/sdk/server/errors.ts +0 -12
  228. package/runtime/sdk/server/host.ts +0 -74
  229. package/runtime/sdk/server/inject.ts +0 -13
  230. package/runtime/sdk/server/runtime.ts +0 -133
  231. package/runtime/sdk/server/validate.ts +0 -87
  232. package/runtime/sdk/ui/NativeView.ts +0 -142
  233. package/runtime/sdk/ui/UI.ts +0 -39
  234. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  235. package/runtime/sdk/ui/UIButton.ts +0 -101
  236. package/runtime/sdk/ui/UIContainer.ts +0 -60
  237. package/runtime/sdk/ui/UIImage.ts +0 -83
  238. package/runtime/sdk/ui/UIInput.ts +0 -185
  239. package/runtime/sdk/ui/UIModal.ts +0 -139
  240. package/runtime/sdk/ui/UINode.ts +0 -830
  241. package/runtime/sdk/ui/UIPager.ts +0 -362
  242. package/runtime/sdk/ui/UIPopover.ts +0 -100
  243. package/runtime/sdk/ui/UIScreen.ts +0 -123
  244. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  245. package/runtime/sdk/ui/UISpacer.ts +0 -14
  246. package/runtime/sdk/ui/UITabs.ts +0 -236
  247. package/runtime/sdk/ui/UIText.ts +0 -51
  248. package/runtime/sdk/ui/UIVideo.ts +0 -88
  249. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  250. package/runtime/sdk/ui/UIWidget.ts +0 -127
  251. package/runtime/sdk/ui/fonts.ts +0 -13
  252. package/runtime/sdk/ui/presentable.ts +0 -117
  253. package/runtime/sdk/ui/router.ts +0 -132
  254. package/runtime/sdk/ui/theme.ts +0 -84
@@ -0,0 +1,891 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { homedir } from "node:os"
4
+ import { basename, dirname, join, resolve } from "node:path"
5
+ import { readManifest, type Manifest } from "../manifest"
6
+ import { loadConfig, requireApiUrl } from "../config"
7
+ import { compileShell, type BundleResource } from "./compile"
8
+ import { CliError, c, flagBool, flagStr, log, note, success, warn, type Args } from "../util"
9
+ import {
10
+ ACCENT_COLOR_JSON, APP_DELEGATE, APP_ICON_JSON, ASSETS_ROOT_JSON, IOS_README, LAUNCH_STORYBOARD,
11
+ SCENE_DELEGATE, SCHEME_XML, VIEW_CONTROLLER, entitlementsTemplate, infoPlistTemplate,
12
+ pbxprojTemplate, runtimePackageTemplate, runtimeSwiftTemplate,
13
+ type RuntimePlugin, type SdkVariant, type ShellIdentity,
14
+ } from "./appTemplates"
15
+ import {
16
+ FIRST_PARTY_PLUGINS, SDK_VARIANTS, cmpVersion, detectEngines, ensureShellIgnored,
17
+ fetchLatestSdkVersion, latestPluginTag, projectRoot, readJson, resolvePlugin, resolveSdkVersion,
18
+ resolveVariant, runTool, slugify, writeJson, VERSION_RE, type AppConfig, type LockEntry,
19
+ } from "./appShared"
20
+ import {
21
+ ANDROID_SDK_MIN_VERSION, ANDROID_SDK_REPO, buildAndroid, doctorAndroidChecks, initAndroid,
22
+ openAndroid, shellSdkVersionAndroid, syncAndroid, type SyncArtifacts,
23
+ } from "./appAndroid"
24
+ import { buildDesktop, initDesktop } from "./appDesktop"
25
+ import { loadIconMaster, writeIosAppIcon } from "./appIcon"
26
+
27
+ /*
28
+ * `lecodes app` — native app shells for a LeCodes project (docs/xcode-app-plan.md).
29
+ *
30
+ * app init ios scaffold <project>/ios/ (Xcode shell + LeCodesRuntime local package)
31
+ * and app.json; `ios/` is added to .lecodesignore (never pushed).
32
+ * The native SDK version is resolved to the latest published release and
33
+ * pinned in app.json ios.sdk (set it to "latest" to re-resolve every sync).
34
+ * app update move the pins forward: app.json ios.sdk → the newest lecodes-ios-sdk
35
+ * release, plugins.lock.json → the newest tag of each unpinned plugin,
36
+ * then re-sync. `--check` reports what's available without writing.
37
+ * app sync make the shell match the project: compile the bundle-first app.js into
38
+ * ios/App/Resources/ + embed every asset it references (server-backed
39
+ * projects included — offline by default; app.json resources.remote globs
40
+ * opt heavy assets back into streaming), pick the SDK variant
41
+ * (core/2d/3d/full) from the engines the bundle uses — plus the filament
42
+ * resources bundle when it bears 3D — vendor the plugins listed in
43
+ * app.json (from their git repos, pinned in plugins.lock.json), regenerate
44
+ * the CLI-owned files (LeCodesRuntime package, Info.plist, versions).
45
+ * app.json "update" configures native OTA: the shell checks that URL at
46
+ * launch and swaps in newer JS on the next one (LeCodesUpdater).
47
+ * app plugin … list / add / remove plugin entries in app.json.
48
+ *
49
+ * Ownership contract: App/*.swift are the USER's after init (sync never touches them);
50
+ * Info.plist, LeCodesRuntime/, App/Resources/ and the shell README are CLI-owned (sync
51
+ * regenerates); the pbxproj is written once — sync only restamps the version values inside it.
52
+ */
53
+
54
+ const SDK_REPO = "letary/lecodes-ios-sdk"
55
+
56
+ // Oldest lecodes-ios-sdk a generated shell can use — 1.2.0 = variant matrix (core/2d/3d/full)
57
+ // + LeCodesUpdater (native OTA), both of which the generated files assume. The version a shell
58
+ // actually links is app.json `ios.sdk` (init pins the newest release, `app update` moves it);
59
+ // this floor is only the last-resort fallback when the network is unavailable at init.
60
+ const SDK_MIN_VERSION = "1.2.0"
61
+
62
+
63
+ // First lecodes-ios-sdk release with `LeCodesEngine.bootProjectUuid` — the shell's world
64
+ // identity (docs/push-plan.md). On older SDKs sync simply doesn't bake the uuid (and warns
65
+ // when push is installed) instead of generating code the linked release can't compile.
66
+ const SDK_IDENTITY_VERSION = "1.6.0"
67
+
68
+ /** The SDK version a synced shell currently links (Package.swift is CLI-owned, so its
69
+ * `exact:` pin is the truth) — the offline fallback, so a sync without network never
70
+ * silently downgrades a shell. `from:` is the pre-pin shape, still read for old shells. */
71
+ const shellSdkVersion = (iosDir: string): string | null => {
72
+ const path = join(iosDir, "LeCodesRuntime", "Package.swift")
73
+ if (!existsSync(path)) return null
74
+ const m = /lecodes-ios-sdk\.git",\s*(?:exact|from):\s*"([^"]+)"/.exec(readFileSync(path, "utf8"))
75
+ return m && VERSION_RE.test(m[1]) ? m[1] : null
76
+ }
77
+
78
+ const IOS_SDK_OPTS = { repo: SDK_REPO, minVersion: SDK_MIN_VERSION, configKey: "ios.sdk" }
79
+
80
+ /* --------------------------------------- init --------------------------------------- */
81
+
82
+ const appInit = async (args: Args) => {
83
+ const platform = args._[1]
84
+ if (platform === "android") return initAndroid(args)
85
+ if (platform === "desktop") return initDesktop(args)
86
+ if (platform !== "ios") {
87
+ throw new CliError(platform
88
+ ? `Unsupported platform "${platform}" — use ios, android or desktop.`
89
+ : "Usage: lecodes app init <ios|android|desktop> [--name <display name>] [--bundle-id <id>] [--team <team id>] [--variant auto|core|2d|3d|full] [--app-id <application id>] [--sdk <version|latest>]")
90
+ }
91
+
92
+ const root = projectRoot()
93
+ const iosDir = join(root, "ios")
94
+ if (existsSync(iosDir)) {
95
+ throw new CliError("ios/ already exists — run `lecodes app sync` to update it.")
96
+ }
97
+
98
+ const existing = readJson<AppConfig>(join(root, "app.json")) ?? {}
99
+ const variant = flagStr(args, "variant") ?? existing.ios?.variant ?? "auto"
100
+ if (variant !== "auto" && !SDK_VARIANTS.includes(variant as SdkVariant)) {
101
+ throw new CliError(`--variant must be auto, ${SDK_VARIANTS.join(", ")} (got "${variant}")`)
102
+ }
103
+
104
+ // The native SDK: a new shell starts on the newest published release, pinned into app.json so
105
+ // builds stay reproducible (`lecodes app update` moves the pin). `--sdk latest` opts into
106
+ // re-resolving on every sync instead.
107
+ const sdkFlag = flagStr(args, "sdk") ?? existing.ios?.sdk
108
+ const sdkVersion = await resolveSdkVersion(process.env.LECODES_IOS_SDK ?? sdkFlag ?? "latest", null, IOS_SDK_OPTS)
109
+ const sdk = sdkFlag === "latest" ? "latest" : sdkVersion
110
+
111
+ const name = flagStr(args, "name") ?? existing.name ?? basename(root)
112
+ const config: AppConfig = {
113
+ ...existing,
114
+ name,
115
+ version: existing.version ?? "1.0.0",
116
+ plugins: existing.plugins ?? {},
117
+ ios: {
118
+ bundleId: flagStr(args, "bundle-id") ?? existing.ios?.bundleId ?? `com.example.${slugify(name)}`,
119
+ teamId: flagStr(args, "team") ?? existing.ios?.teamId ?? "",
120
+ variant,
121
+ sdk,
122
+ buildNumber: existing.ios?.buildNumber ?? 1,
123
+ },
124
+ }
125
+ writeJson(join(root, "app.json"), config)
126
+ log(`${c.green("written")} app.json`)
127
+
128
+ // Never push the shell to the platform.
129
+ ensureShellIgnored(root, "ios")
130
+
131
+ const identity: ShellIdentity = {
132
+ displayName: name,
133
+ bundleId: config.ios!.bundleId!,
134
+ teamId: config.ios!.teamId!,
135
+ version: config.version!,
136
+ buildNumber: config.ios!.buildNumber!,
137
+ }
138
+
139
+ const write = (rel: string, content: string) => {
140
+ const abs = join(iosDir, rel)
141
+ mkdirSync(dirname(abs), { recursive: true })
142
+ writeFileSync(abs, content)
143
+ log(`${c.green("created")} ios/${rel}`)
144
+ }
145
+
146
+ write("README.md", IOS_README)
147
+ write("App.xcodeproj/project.pbxproj", pbxprojTemplate(identity))
148
+ write("App.xcodeproj/xcshareddata/xcschemes/App.xcscheme", SCHEME_XML)
149
+ write("App/AppDelegate.swift", APP_DELEGATE)
150
+ write("App/SceneDelegate.swift", SCENE_DELEGATE)
151
+ write("App/ViewController.swift", VIEW_CONTROLLER)
152
+ write("App/Info.plist", infoPlistTemplate({}))
153
+ write("App/App.entitlements", entitlementsTemplate({}))
154
+ write("App/Base.lproj/LaunchScreen.storyboard", LAUNCH_STORYBOARD)
155
+ write("App/Assets.xcassets/Contents.json", ASSETS_ROOT_JSON)
156
+ write("App/Assets.xcassets/AccentColor.colorset/Contents.json", ACCENT_COLOR_JSON)
157
+ write("App/Assets.xcassets/AppIcon.appiconset/Contents.json", APP_ICON_JSON)
158
+ // "auto" resolves at sync time (the compiled bundle says which engines it uses); until the
159
+ // first sync the shell gets the slimmest product as a placeholder.
160
+ write("LeCodesRuntime/Package.swift", runtimePackageTemplate(sdkVersion, variant === "auto" ? "core" : variant as SdkVariant, []))
161
+ write("LeCodesRuntime/Sources/LeCodesRuntime/LeCodesRuntime.swift", runtimeSwiftTemplate([]))
162
+
163
+ log("")
164
+ log(`${c.green("sdk")} lecodes-ios-sdk ${sdkVersion}${sdk === "latest" ? c.dim(" (ios.sdk: latest — re-resolved on every sync)") : c.dim(" (pinned in app.json ios.sdk — `lecodes app update` moves it)")}`)
165
+ success(`iOS shell for ${c.bold(name)} ready.`)
166
+ log(` ${c.bold("lecodes app sync")} embed the compiled bundle (+ plugins) into the shell`)
167
+ log(` ${c.bold("open ios/App.xcodeproj")} then run on your device from Xcode`)
168
+ log(` App icon: set app.json "icon" to a square ≥1024px image — sync renders every size (or drop a PNG into App/Assets.xcassets/AppIcon.appiconset/)`)
169
+ }
170
+
171
+ /* --------------------------------------- sync --------------------------------------- */
172
+
173
+
174
+ /** The filament resources bundle that must ship beside any CreatorGL-bearing variant (3d/full).
175
+ * Published as a release asset next to the SDK binaries; downloaded once per SDK version into
176
+ * ~/.lecodes/sdk-resources/. `LECODES_SDK_RESOURCES` overrides with a local .bundle — the
177
+ * SDK-development loop, like LECODES_PLUGINS_DIR for plugins. */
178
+ const ensureSdkResourcesBundle = async (sdkVersion: string): Promise<string> => {
179
+ const override = process.env.LECODES_SDK_RESOURCES
180
+ if (override) {
181
+ note(`Using local resources bundle ${override} (LECODES_SDK_RESOURCES)`)
182
+ return resolve(override)
183
+ }
184
+ const dir = join(homedir(), ".lecodes", "sdk-resources", sdkVersion)
185
+ const bundle = join(dir, "LeCodesSDKResources.bundle")
186
+ if (existsSync(bundle)) return bundle
187
+ const url = `https://github.com/${SDK_REPO}/releases/download/${sdkVersion}/LeCodesSDKResources.bundle.zip`
188
+ note(`Downloading LeCodesSDKResources.bundle (SDK ${sdkVersion})…`)
189
+ const res = await fetch(url)
190
+ if (!res.ok) throw new CliError(`Resources bundle download failed (HTTP ${res.status}) — ${url}`)
191
+ mkdirSync(dir, { recursive: true })
192
+ const zip = join(dir, "LeCodesSDKResources.bundle.zip")
193
+ writeFileSync(zip, new Uint8Array(await res.arrayBuffer()))
194
+ execFileSync("ditto", ["-x", "-k", zip, dir]) // release zips are ditto archives (--keepParent)
195
+ rmSync(zip)
196
+ if (!existsSync(bundle)) throw new CliError(`No LeCodesSDKResources.bundle inside the release asset ${url}`)
197
+ return bundle
198
+ }
199
+
200
+ /** Resolve app.json "update" to the URL the shell's native updater checks. `true` means the
201
+ * project's live bundle on the platform (`/code/<uuid>.js` — needs a server-backed project);
202
+ * strings / { url } pass through. Warns on http: ATS blocks it on device. */
203
+ const resolveUpdateUrl = (update: AppConfig["update"], root: string): string | undefined => {
204
+ if (!update) return undefined
205
+ let url = typeof update === "string" ? update : typeof update === "object" ? update.url : undefined
206
+ if (!url) {
207
+ let manifest: Manifest | null = null
208
+ try { manifest = readManifest(root) } catch { /* not a project dir */ }
209
+ if (!manifest) {
210
+ warn(`app.json "update": true needs a server-backed project — use "update": {"url": "https://…/app.js"} for custom hosting. OTA left unconfigured.`)
211
+ return undefined
212
+ }
213
+ url = `${requireApiUrl(loadConfig())}/code/${manifest.uuid}.js`
214
+ }
215
+ if (!url.startsWith("https://")) warn(`update URL is not https — iOS blocks it on device (ATS): ${url}`)
216
+ return url
217
+ }
218
+
219
+ /** `lecodes app sync [ios|android]` — no platform syncs every shell dir that exists. A
220
+ * dual-shell project compiles the bundle ONCE (the iOS pass) and the Android pass reuses
221
+ * the byte-identical output. */
222
+ const appSync = async (args: Args) => {
223
+ const root = projectRoot()
224
+ const config = readJson<AppConfig>(join(root, "app.json"))
225
+ const platform = args._[1]
226
+ if (platform !== undefined && platform !== "ios" && platform !== "android") {
227
+ throw new CliError(`Unknown platform "${platform}" — use ios or android.`)
228
+ }
229
+ const hasIos = !!config?.ios && existsSync(join(root, "ios"))
230
+ const hasAndroid = !!config?.android && existsSync(join(root, "android"))
231
+ if (platform === "ios" && !hasIos) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
232
+ if (platform === "android" && !hasAndroid) throw new CliError("No Android shell here — run `lecodes app init android` first.")
233
+ if (!platform && !hasIos && !hasAndroid) {
234
+ throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
235
+ }
236
+
237
+ let iosArtifacts: SyncArtifacts | null = null
238
+ if (hasIos && platform !== "android") iosArtifacts = await syncIos(root, config!)
239
+ if (hasAndroid && platform !== "ios") await syncAndroid(root, config!, iosArtifacts)
240
+ }
241
+
242
+ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts> => {
243
+ const iosDir = join(root, "ios")
244
+ if (!config.ios) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
245
+ // The variant is resolved after the compile below (the bundle header says which engines the
246
+ // project uses) — but reject a mistyped forced value before doing any work.
247
+ const requestedVariant = config.ios.variant ?? "auto"
248
+ if (requestedVariant !== "auto" && !SDK_VARIANTS.includes(requestedVariant as SdkVariant)) {
249
+ throw new CliError(`app.json ios.variant must be "auto" or one of ${SDK_VARIANTS.join(", ")} (got "${requestedVariant}").`)
250
+ }
251
+
252
+ // The native SDK release this shell links: app.json ios.sdk, or — for shells scaffolded before
253
+ // that key existed — whatever Package.swift already names, so a routine sync never moves a
254
+ // project's native SDK on its own (`lecodes app update` does that). "latest" re-resolves here,
255
+ // degrading to the linked version when offline.
256
+ const linkedSdk = shellSdkVersion(iosDir)
257
+ const sdkPin = process.env.LECODES_IOS_SDK ?? config.ios.sdk ?? linkedSdk ?? "latest"
258
+ const sdkVersion = await resolveSdkVersion(sdkPin, linkedSdk, IOS_SDK_OPTS)
259
+
260
+ // ---- plugins: vendor sources, collect registration + Info.plist keys, pin the lock ----
261
+ const lockPath = join(root, "plugins.lock.json")
262
+ const oldLock = readJson<{ plugins?: Record<string, LockEntry> }>(lockPath)?.plugins ?? {}
263
+ const pluginSpecs = Object.entries(config.plugins ?? {})
264
+ const runtimePlugins: RuntimePlugin[] = []
265
+ const infoValues: Record<string, string[]> = {}
266
+ const entitlementValues: Record<string, unknown[]> = {}
267
+ const newLock: Record<string, LockEntry> = {}
268
+
269
+ const sourcesDir = join(iosDir, "LeCodesRuntime", "Sources")
270
+ for (const [id, spec] of pluginSpecs) {
271
+ const plugin = resolvePlugin(id, spec, oldLock[id], "ios")
272
+ const ios = plugin.manifest.ios!
273
+ const module = ios.module
274
+ const register = ios.register
275
+ if (!module || !register) throw new CliError(`Plugin "${id}": manifest ios.module / ios.register missing`)
276
+
277
+ const src = join(plugin.dir, ios.sources ?? "ios")
278
+ if (!existsSync(src)) throw new CliError(`Plugin "${id}": sources dir ${src} missing`)
279
+ const dest = join(sourcesDir, module)
280
+ rmSync(dest, { recursive: true, force: true })
281
+ cpSync(src, dest, { recursive: true })
282
+
283
+ // Hard error, not a warning: below minSdk the vendored sources reference SDK API that
284
+ // does not exist in the linked release, so the shell cannot compile anyway.
285
+ if (ios.minSdk && VERSION_RE.test(ios.minSdk) && cmpVersion(sdkVersion, ios.minSdk) < 0) {
286
+ throw new CliError(`Plugin "${id}" needs lecodes-ios-sdk ${ios.minSdk} but the shell links ${sdkVersion} — run \`lecodes app update\` (or set app.json ios.sdk).`)
287
+ }
288
+ for (const [key, value] of Object.entries(ios.infoPlist ?? {})) {
289
+ (infoValues[key] ??= []).push(value)
290
+ }
291
+ for (const [key, value] of Object.entries(ios.entitlements ?? {})) {
292
+ (entitlementValues[key] ??= []).push(value)
293
+ }
294
+ runtimePlugins.push({ id, module, register })
295
+ newLock[id] = plugin.lock
296
+ log(`${c.green("plugin")} ${id}@${plugin.lock.version} (${plugin.lock.tag} → Sources/${module})`)
297
+ }
298
+
299
+ // Remove targets of uninstalled plugins (everything under Sources/ except the runtime itself).
300
+ const keep = new Set(["LeCodesRuntime", ...runtimePlugins.map(p => p.module)])
301
+ for (const entry of existsSync(sourcesDir) ? readdirSync(sourcesDir) : []) {
302
+ if (!keep.has(entry) && statSync(join(sourcesDir, entry)).isDirectory()) {
303
+ rmSync(join(sourcesDir, entry), { recursive: true })
304
+ log(`${c.yellow("removed")} ios/LeCodesRuntime/Sources/${entry} (plugin uninstalled)`)
305
+ }
306
+ }
307
+
308
+ // ---- bundle: compile self-contained into Resources/, then embed the assets it references ----
309
+ const remotePatterns = config.resources?.remote ?? []
310
+ if (!Array.isArray(remotePatterns) || remotePatterns.some((p) => typeof p !== "string")) {
311
+ throw new CliError('app.json "resources.remote" must be an array of glob strings (e.g. ["videos/**"]).')
312
+ }
313
+ const resourcesDir = join(iosDir, "App", "Resources")
314
+ rmSync(resourcesDir, { recursive: true, force: true })
315
+ mkdirSync(resourcesDir, { recursive: true })
316
+ const resources = await compileShell({ _: [], flags: { o: join(resourcesDir, "app.js") } }, { remotePatterns })
317
+
318
+ // ---- variant: the smallest SDK product covering what the bundle uses (or the forced one) ----
319
+ const bundleJs = readFileSync(join(resourcesDir, "app.js"), "utf8")
320
+ const { variant, detected } = resolveVariant(requestedVariant, bundleJs)
321
+ const WHY: Record<SdkVariant, string> = {
322
+ core: "UI only", "2d": "uses the 2D engine", "3d": "uses the 3D engine", full: "uses the 3D + 2D engines",
323
+ }
324
+ log(`${c.green("variant")} ${variant}${requestedVariant === "auto" ? ` (auto: ${WHY[detected]})` : " (forced by ios.variant)"}`)
325
+ const sdkWhy = sdkPin === "latest" ? "latest" : config.ios.sdk ? "pinned by ios.sdk" : "from the shell — set ios.sdk or run `lecodes app update`"
326
+ log(`${c.green("sdk")} lecodes-ios-sdk ${sdkVersion}${c.dim(` (${sdkWhy})`)}`)
327
+
328
+ // The filament resources ride along with any CreatorGL-bearing variant. Its files also back
329
+ // fetchLocal for SDK built-ins (lit.filamat & co), so the embed step below can skip them.
330
+ const sdkProvided = new Set<string>()
331
+ if (variant === "3d" || variant === "full") {
332
+ const sdkResources = await ensureSdkResourcesBundle(sdkVersion)
333
+ cpSync(sdkResources, join(resourcesDir, "LeCodesSDKResources.bundle"), { recursive: true })
334
+ for (const f of readdirSync(sdkResources)) if (f !== "Info.plist") sdkProvided.add(f)
335
+ log(`${c.green("resources")} LeCodesSDKResources.bundle (filament) → ios/App/Resources/`)
336
+ }
337
+
338
+ // Embed exactly what the bundle resolves locally: the `// preload:` header names every
339
+ // fetchLocal resource (vendored fonts included). resources.remote entries stayed URLs, and
340
+ // an asset the code never references isn't shipped. Copies are flat by lookup name — the
341
+ // host's setFetchLocal reads Resources/ by basename, falling back to the SDK resources
342
+ // bundle for the sdkProvided names.
343
+ const preload = /^\/\/ preload: (.*)$/m.exec(bundleJs)?.[1].split(";").map((s) => s.trim()).filter(Boolean) ?? []
344
+ const IMPLICIT_PRELOADS = new Set(["uberarchive", "capture"]) // engine-synthesized, not project files
345
+ const byName = new Map<string, BundleResource[]>()
346
+ for (const r of resources) {
347
+ if (r.remote || !r.absPath) continue
348
+ const list = byName.get(r.name) ?? []
349
+ list.push(r)
350
+ byName.set(r.name, list)
351
+ }
352
+ let embedded = 0
353
+ for (const name of preload) {
354
+ if (IMPLICIT_PRELOADS.has(name)) continue
355
+ const candidates = byName.get(name) ?? []
356
+ if (candidates.length === 0) {
357
+ if (!sdkProvided.has(name)) warn(`bundle needs local asset "${name}" but no project file provides it — it won't load on device`)
358
+ continue
359
+ }
360
+ if (candidates.length > 1) warn(`asset name collision: ${name} (${candidates.map((r) => r.path).join(", ")}) — embedding ${candidates[0].path}`)
361
+ cpSync(candidates[0].absPath!, join(resourcesDir, name))
362
+ embedded++
363
+ }
364
+ if (embedded > 0) log(`${c.green("assets")} ${embedded} file(s) embedded → ios/App/Resources/`)
365
+ const streamed = resources.filter((r) => r.remote)
366
+ if (streamed.length > 0) log(`${c.green("remote")} ${streamed.length} resource(s) stay on the server (resources.remote): ${streamed.map((r) => r.path).join(", ")}`)
367
+
368
+ // ---- identity: a server-backed shell bakes its project uuid into the runtime, so
369
+ // identity-gated services (push registration) see the app's own uuid. Gated on the SDK
370
+ // release that carries LeCodesEngine.bootProjectUuid — on older SDKs the uuid is simply
371
+ // not baked (register() rejects "unavailable" exactly as before).
372
+ let shellUuid: string | undefined
373
+ try { shellUuid = readManifest(root).uuid } catch { /* local-only project — unattributed */ }
374
+ if (shellUuid && cmpVersion(sdkVersion, SDK_IDENTITY_VERSION) < 0) {
375
+ if (runtimePlugins.some(p => p.id === "push")) {
376
+ warn(`push needs the shell's world identity, which lecodes-ios-sdk ${SDK_IDENTITY_VERSION}+ carries — this shell links ${sdkVersion}, so Push.register() will reject "unavailable". Run \`lecodes app update\` when ${SDK_IDENTITY_VERSION} is out.`)
377
+ }
378
+ shellUuid = undefined
379
+ }
380
+ if (shellUuid) log(`${c.green("identity")} project ${shellUuid} baked into the shell (world identity)`)
381
+
382
+ // ---- regenerate the CLI-owned files ----
383
+ writeFileSync(join(iosDir, "README.md"), IOS_README)
384
+ writeFileSync(join(iosDir, "LeCodesRuntime", "Package.swift"), runtimePackageTemplate(sdkVersion, variant, runtimePlugins))
385
+ writeFileSync(join(sourcesDir, "LeCodesRuntime", "LeCodesRuntime.swift"), runtimeSwiftTemplate(runtimePlugins, shellUuid))
386
+
387
+ const mergedKeys: Record<string, string> = {}
388
+ for (const [key, values] of Object.entries(infoValues)) mergedKeys[key] = [...new Set(values)].join(" ")
389
+ const updateUrl = resolveUpdateUrl(config.update, root)
390
+ writeFileSync(join(iosDir, "App", "Info.plist"), infoPlistTemplate(mergedKeys, updateUrl))
391
+ if (updateUrl) log(`${c.green("update")} OTA source ${updateUrl} — checked natively at launch, new code applies next launch`)
392
+ else if (!config.update) {
393
+ // Discoverability: server-backed projects get OTA with one app.json key, but nothing
394
+ // else ever mentions it. One dim line, only while unconfigured.
395
+ let manifest: Manifest | null = null
396
+ try { manifest = readManifest(root) } catch { /* not a project dir */ }
397
+ if (manifest) note(`tip: add "update": true to app.json for over-the-air JS updates — ship with \`lecodes push --publish\`, no store release`)
398
+ }
399
+
400
+ if (pluginSpecs.length > 0 || existsSync(lockPath)) writeJson(lockPath, { plugins: newLock })
401
+
402
+ // Version restamp — values only, never structure (the pbxproj is otherwise init-owned).
403
+ const pbxPath = join(iosDir, "App.xcodeproj", "project.pbxproj")
404
+ let pbx = readFileSync(pbxPath, "utf8")
405
+ .replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${config.version ?? "1.0.0"};`)
406
+ .replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${config.ios.buildNumber ?? 1};`)
407
+
408
+ // ---- entitlements: the capabilities the installed plugins declare (push's aps-environment).
409
+ // App/App.entitlements is CLI-owned and regenerated whenever the pbxproj references it (every
410
+ // shell scaffolded with one) or a plugin needs one. Shells from before the entitlements era
411
+ // get the CODE_SIGN_ENTITLEMENTS reference injected the first time a plugin brings a
412
+ // capability — the ONE structural pbxproj edit sync ever makes, idempotent and line-shaped.
413
+ const mergedEntitlements: Record<string, unknown> = {}
414
+ for (const [key, values] of Object.entries(entitlementValues)) {
415
+ if (values.every(v => Array.isArray(v))) {
416
+ const seen = new Set<string>()
417
+ mergedEntitlements[key] = (values as unknown[][]).flat()
418
+ .filter(v => { const s = JSON.stringify(v); if (seen.has(s)) return false; seen.add(s); return true })
419
+ } else {
420
+ if (new Set(values.map(v => JSON.stringify(v))).size > 1) {
421
+ warn(`entitlement ${key}: plugins declare conflicting values — keeping ${JSON.stringify(values[0])}`)
422
+ }
423
+ mergedEntitlements[key] = values[0]
424
+ }
425
+ }
426
+ const hasEntitlements = Object.keys(mergedEntitlements).length > 0
427
+ if (pbx.includes("CODE_SIGN_ENTITLEMENTS") || hasEntitlements) {
428
+ writeFileSync(join(iosDir, "App", "App.entitlements"), entitlementsTemplate(mergedEntitlements))
429
+ if (hasEntitlements) log(`${c.green("caps")} App/App.entitlements: ${Object.keys(mergedEntitlements).sort().join(", ")}`)
430
+ if (!pbx.includes("CODE_SIGN_ENTITLEMENTS")) {
431
+ const patched = pbx.replace(/^(\s*)CODE_SIGN_STYLE = /gm, `$1CODE_SIGN_ENTITLEMENTS = "App/App.entitlements";\n$1CODE_SIGN_STYLE = `)
432
+ if (patched === pbx) {
433
+ warn(`couldn't wire App.entitlements into the pbxproj — add CODE_SIGN_ENTITLEMENTS = "App/App.entitlements" to the App target's build settings in Xcode.`)
434
+ } else {
435
+ pbx = patched.replace(/^(\s*)membershipExceptions = \(\n(\s*)Info\.plist,/m, `$1membershipExceptions = (\n$2App.entitlements,\n$2Info.plist,`)
436
+ log(`${c.green("updated")} App.xcodeproj: CODE_SIGN_ENTITLEMENTS → App/App.entitlements`)
437
+ }
438
+ }
439
+ }
440
+ writeFileSync(pbxPath, pbx)
441
+
442
+ // ---- icons: rendered from app.json "icon" when set (the appiconset becomes a CLI-owned
443
+ // carve-out like Resources/) — absent, a hand-dropped AppIcon.png stays untouched ----
444
+ if (config.icon) {
445
+ await writeIosAppIcon(iosDir, await loadIconMaster(root, config))
446
+ }
447
+
448
+ log("")
449
+ success(`Shell synced: app.js + ${runtimePlugins.length} plugin(s), SDK ${sdkVersion}, variant ${variant}, v${config.version ?? "1.0.0"} (${config.ios.buildNumber ?? 1}).`)
450
+ note("Build & run from Xcode (open ios/App.xcodeproj) — user files in ios/App/*.swift were not touched.")
451
+ return { bundleJs, resources }
452
+ }
453
+
454
+ /* -------------------------------------- plugin -------------------------------------- */
455
+
456
+ const appPlugin = async (args: Args) => {
457
+ const sub = args._[1]
458
+ const root = projectRoot()
459
+ const configPath = join(root, "app.json")
460
+ const config = readJson<AppConfig>(configPath)
461
+ if (!config) throw new CliError("No app.json here — run `lecodes app init <ios|android>` first.")
462
+ config.plugins ??= {}
463
+
464
+ if (sub === "list" || sub === undefined) {
465
+ const entries = Object.entries(config.plugins)
466
+ if (entries.length === 0) { log("No plugins installed."); return }
467
+ const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
468
+ for (const [id, spec] of entries) {
469
+ const pin = lock[id] ? c.dim(` ${lock[id].repo} ${lock[id].tag} (${lock[id].commit.slice(0, 7)})`) : c.dim(" (not synced)")
470
+ log(`${c.bold(id)} ${spec}${pin}`)
471
+ }
472
+ return
473
+ }
474
+
475
+ const id = args._[2]
476
+ if (!id) throw new CliError(`Usage: lecodes app plugin ${sub} <id | github:owner/repo[@tag]>`)
477
+
478
+ if (sub === "add") {
479
+ if (id.startsWith("github:")) {
480
+ const bare = id.slice("github:".length).split("@")[0].split("/").pop()!
481
+ config.plugins[bare] = id
482
+ log(`${c.green("added")} ${bare} → ${id} ${c.dim("(third-party native code — review it before shipping)")}`)
483
+ } else if (FIRST_PARTY_PLUGINS[id]) {
484
+ config.plugins[id] = "*"
485
+ log(`${c.green("added")} ${id}`)
486
+ } else {
487
+ throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use github:owner/repo[@tag].`)
488
+ }
489
+ writeJson(configPath, config)
490
+ note("Run `lecodes app sync` to vendor it into the shell.")
491
+ return
492
+ }
493
+
494
+ if (sub === "remove") {
495
+ if (!(id in config.plugins)) throw new CliError(`Plugin "${id}" is not installed.`)
496
+ delete config.plugins[id]
497
+ writeJson(configPath, config)
498
+ log(`${c.yellow("removed")} ${id}`)
499
+ note("Run `lecodes app sync` to drop its sources from the shell.")
500
+ return
501
+ }
502
+
503
+ throw new CliError(`Unknown subcommand "plugin ${sub}" — use list, add or remove.`)
504
+ }
505
+
506
+ /* ---------------------------------- build & upload ----------------------------------- */
507
+
508
+ const exportOptionsPlist = (method: string, teamId: string, destination?: "upload"): string => `<?xml version="1.0" encoding="UTF-8"?>
509
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
510
+ <plist version="1.0">
511
+ <dict>
512
+ <key>method</key>
513
+ <string>${method}</string>
514
+ <key>signingStyle</key>
515
+ <string>automatic</string>${teamId ? `
516
+ <key>teamID</key>
517
+ <string>${teamId}</string>` : ""}${destination ? `
518
+ <key>destination</key>
519
+ <string>${destination}</string>` : ""}
520
+ </dict>
521
+ </plist>
522
+ `
523
+
524
+ const requireShell = (): { root: string, config: AppConfig } => {
525
+ const root = projectRoot()
526
+ const config = readJson<AppConfig>(join(root, "app.json"))
527
+ if (!config?.ios || !existsSync(join(root, "ios"))) {
528
+ throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
529
+ }
530
+ return { root, config }
531
+ }
532
+
533
+ /* -------------------------------------- update --------------------------------------- */
534
+
535
+ /** One platform's SDK-pin movement inside `app update` — identical flow for both arms, only
536
+ * the labels/repos differ. Mutates config[platform].sdk (checkOnly writes nothing);
537
+ * @return true when something changed. */
538
+ const updateSdkPin = async (
539
+ root: string, config: AppConfig, args: Args, checkOnly: boolean,
540
+ platform: "ios" | "android",
541
+ ): Promise<boolean> => {
542
+ const [label, repo, minVersion, shellVersion] = platform === "ios"
543
+ ? ["lecodes-ios-sdk", SDK_REPO, SDK_MIN_VERSION, () => shellSdkVersion(join(root, "ios"))] as const
544
+ : ["lecodes-android-sdk", ANDROID_SDK_REPO, ANDROID_SDK_MIN_VERSION, () => shellSdkVersionAndroid(join(root, "android"))] as const
545
+ const section = config[platform]!
546
+ const wanted = flagStr(args, "sdk")
547
+ let changed = false
548
+
549
+ // No sdk key = an old shell: its CLI-owned build file is the pin sync would keep.
550
+ const pin = section.sdk ?? shellVersion() ?? "latest"
551
+ const current = pin === "latest" ? (shellVersion() ?? minVersion) : pin
552
+ let nextPin: string
553
+ if (wanted === "latest") {
554
+ nextPin = "latest" // float: every sync re-resolves
555
+ } else if (wanted) {
556
+ if (!VERSION_RE.test(wanted)) throw new CliError(`--sdk must be "latest" or an exact x.y.z release (got "${wanted}").`)
557
+ if (cmpVersion(wanted, minVersion) < 0) throw new CliError(`--sdk ${wanted} is older than the ${minVersion} these generated shells need.`)
558
+ nextPin = wanted
559
+ } else {
560
+ nextPin = await fetchLatestSdkVersion(repo)
561
+ }
562
+ const nextVersion = nextPin === "latest" ? await fetchLatestSdkVersion(repo) : nextPin
563
+
564
+ if (nextVersion === current && nextPin === pin) {
565
+ log(`${c.green("sdk")} ${label} ${current} ${c.dim("(already current)")}`)
566
+ } else if (nextVersion === current) {
567
+ log(`${c.green("sdk")} ${label} ${current} → ${platform}.sdk ${nextPin} ${c.dim("(same version, pin mode changed)")}`)
568
+ changed = true
569
+ } else {
570
+ const verb = cmpVersion(nextVersion, current) < 0 ? c.yellow("downgrade") : c.green("update")
571
+ log(`${c.green("sdk")} ${label} ${current} → ${c.bold(nextVersion)} ${c.dim(`(${verb})`)}`)
572
+ changed = true
573
+ }
574
+ if (!checkOnly && nextPin !== pin) {
575
+ section.sdk = nextPin
576
+ writeJson(join(root, "app.json"), config)
577
+ }
578
+ return changed
579
+ }
580
+
581
+ /** `lecodes app update [ios|android]` — move a shell's pins forward, the way `app sync` moves
582
+ * its code: app.json ios.sdk / android.sdk → the newest published SDK release,
583
+ * plugins.lock.json → the newest tag of every plugin whose app.json spec doesn't name one
584
+ * (a `github:owner/repo@tag` spec is a deliberate pin and is left alone). Then re-syncs so
585
+ * the shell actually carries the new versions — `--check` reports and writes nothing. */
586
+ const appUpdate = async (args: Args) => {
587
+ const root = projectRoot()
588
+ const config = readJson<AppConfig>(join(root, "app.json"))
589
+ const platform = args._[1]
590
+ if (platform !== undefined && platform !== "ios" && platform !== "android") {
591
+ throw new CliError(`Unknown platform "${platform}" — use ios or android.`)
592
+ }
593
+ const hasIos = !!config?.ios && existsSync(join(root, "ios"))
594
+ const hasAndroid = !!config?.android && existsSync(join(root, "android"))
595
+ if (platform === "ios" && !hasIos) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
596
+ if (platform === "android" && !hasAndroid) throw new CliError("No Android shell here — run `lecodes app init android` first.")
597
+ if (!hasIos && !hasAndroid) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
598
+ const doIos = hasIos && platform !== "android"
599
+ const doAndroid = hasAndroid && platform !== "ios"
600
+ if (flagStr(args, "sdk") && doIos && doAndroid) {
601
+ throw new CliError("--sdk is ambiguous with two shells — run `lecodes app update ios --sdk …` or `lecodes app update android --sdk …`.")
602
+ }
603
+
604
+ const checkOnly = flagBool(args, "check")
605
+ let changed = false
606
+
607
+ // ---- SDK pins ----
608
+ if (doIos) changed = (await updateSdkPin(root, config!, args, checkOnly, "ios")) || changed
609
+ if (doAndroid) changed = (await updateSdkPin(root, config!, args, checkOnly, "android")) || changed
610
+
611
+ // ---- plugins ----
612
+ const lockPath = join(root, "plugins.lock.json")
613
+ const lock = readJson<{ plugins?: Record<string, LockEntry> }>(lockPath)?.plugins ?? {}
614
+ const specs = Object.entries(config!.plugins ?? {})
615
+ if (specs.length > 0 && !flagBool(args, "no-plugins")) {
616
+ const kept: Record<string, LockEntry> = {}
617
+ for (const [id, spec] of specs) {
618
+ const entry = lock[id]
619
+ if (spec.startsWith("github:") && spec.includes("@")) {
620
+ log(`${c.green("plugin")} ${id} ${c.dim(`pinned by app.json (${spec}) — not updated`)}`)
621
+ if (entry) kept[id] = entry
622
+ continue
623
+ }
624
+ const repo = spec.startsWith("github:") ? spec.slice("github:".length) : FIRST_PARTY_PLUGINS[id]
625
+ const tag = repo ? latestPluginTag(repo) : null
626
+ if (!tag) {
627
+ log(`${c.yellow("plugin")} ${id} ${c.dim("could not read its latest tag — left as is")}`)
628
+ if (entry) kept[id] = entry
629
+ continue
630
+ }
631
+ if (entry && entry.tag === tag) {
632
+ log(`${c.green("plugin")} ${id} ${entry.tag} ${c.dim("(already current)")}`)
633
+ kept[id] = entry
634
+ continue
635
+ }
636
+ log(`${c.green("plugin")} ${id} ${entry?.tag ?? "—"} → ${c.bold(tag)}`)
637
+ changed = true // dropped from the lock: sync re-pins it
638
+ }
639
+ if (!checkOnly && existsSync(lockPath)) writeJson(lockPath, { plugins: kept })
640
+ }
641
+
642
+ log("")
643
+ if (checkOnly) {
644
+ note(changed ? "Run `lecodes app update` to apply." : "Everything is up to date.")
645
+ return
646
+ }
647
+ if (!changed) { success("Already up to date."); return }
648
+ if (flagBool(args, "no-sync")) {
649
+ success("Pins updated — run `lecodes app sync` to apply them to the shell.")
650
+ return
651
+ }
652
+ await appSync({ _: platform ? ["sync", platform] : [], flags: {} })
653
+ }
654
+
655
+ const appDoctor = async (_args: Args) => {
656
+ const root = projectRoot()
657
+ const config = readJson<AppConfig>(join(root, "app.json"))
658
+ const hasIos = !!config?.ios && existsSync(join(root, "ios"))
659
+ const hasAndroid = !!config?.android && existsSync(join(root, "android"))
660
+ if (!hasIos && !hasAndroid) {
661
+ throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
662
+ }
663
+ let failed = 0
664
+ const check = (ok: boolean, label: string, hint = "", fatal = true) => {
665
+ log(`${ok ? c.green("✓") : (fatal ? c.red("✗") : c.yellow("!"))} ${label}${ok || !hint ? "" : c.dim(" — " + hint)}`)
666
+ if (!ok && fatal) failed++
667
+ }
668
+
669
+ if (config?.icon) {
670
+ check(existsSync(join(root, config.icon)), `Icon master ${config.icon}`, `app.json "icon" points at a missing file — sync will fail`)
671
+ }
672
+ if (hasIos) await doctorIosChecks(root, config!, check)
673
+ if (hasAndroid) {
674
+ await doctorAndroidChecks(root, config!, check)
675
+ if (!hasIos) {
676
+ const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
677
+ const wanted = Object.keys(config!.plugins ?? {})
678
+ const inSync = wanted.every(id => lock[id]) && Object.keys(lock).every(id => wanted.includes(id))
679
+ check(wanted.length === 0 || inSync, "Plugins synced", "run `lecodes app sync`")
680
+ }
681
+ }
682
+
683
+ log("")
684
+ if (failed > 0) throw new CliError(`${failed} check(s) failed.`)
685
+ success("Ready to build.")
686
+ }
687
+
688
+ const doctorIosChecks = async (
689
+ root: string, config: AppConfig,
690
+ check: (ok: boolean, label: string, hint?: string, fatal?: boolean) => void,
691
+ ) => {
692
+ let xcodeVersion = ""
693
+ try { xcodeVersion = runTool(root, "xcodebuild", ["-version"]).split("\n")[0] } catch { /* absent */ }
694
+ check(xcodeVersion !== "", `Xcode CLI (${xcodeVersion || "not found"})`, "install Xcode + command line tools")
695
+ check(!!config.ios!.teamId, "Development team set", "lecodes app init wrote app.json — fill ios.teamId", false)
696
+
697
+ const iconDir = join(root, "ios", "App", "Assets.xcassets", "AppIcon.appiconset")
698
+ const hasIcon = existsSync(iconDir) && readdirSync(iconDir).some(f => f.endsWith(".png"))
699
+ check(hasIcon, "App icon present", "drop a 1024×1024 PNG into ios/App/Assets.xcassets/AppIcon.appiconset/ (App Store requires it)", false)
700
+
701
+ const bundle = join(root, "ios", "App", "Resources", "app.js")
702
+ check(existsSync(bundle), "Bundled app.js", "run `lecodes app sync`")
703
+
704
+ // Variant sanity: valid config value; the synced shell links the product the bundle needs
705
+ // and carries the filament resources when that product bears CreatorGL.
706
+ const requestedVariant = config.ios!.variant ?? "auto"
707
+ const validVariant = requestedVariant === "auto" || SDK_VARIANTS.includes(requestedVariant as SdkVariant)
708
+ check(validVariant, `ios.variant "${requestedVariant}"`, `use "auto" or one of ${SDK_VARIANTS.join(", ")}`)
709
+ if (existsSync(bundle) && validVariant) {
710
+ const { variant } = resolveVariant(requestedVariant, readFileSync(bundle, "utf8"))
711
+ const pkg = readFileSync(join(root, "ios", "LeCodesRuntime", "Package.swift"), "utf8")
712
+ const needsResources = variant === "3d" || variant === "full"
713
+ const hasResources = existsSync(join(root, "ios", "App", "Resources", "LeCodesSDKResources.bundle"))
714
+ check(pkg.includes(`"LeCodesSDK-${variant}"`) && (!needsResources || hasResources),
715
+ `SDK variant ${variant}${needsResources ? " (+ filament resources)" : ""}`, "run `lecodes app sync`")
716
+ }
717
+
718
+ // SDK: the shell must link the version app.json asks for, and (soft, needs network) that
719
+ // should be the newest published release.
720
+ const linked = shellSdkVersion(join(root, "ios"))
721
+ const pin = config.ios!.sdk ?? "latest"
722
+ check(!!linked, `SDK ${linked ?? "version unreadable"}`, "run `lecodes app sync`")
723
+ if (linked && pin !== "latest") {
724
+ check(linked === pin, `SDK matches app.json ios.sdk (${pin})`, "run `lecodes app sync`")
725
+ }
726
+ if (linked) {
727
+ try {
728
+ const latest = await fetchLatestSdkVersion(SDK_REPO)
729
+ check(cmpVersion(linked, latest) >= 0, `SDK is the latest release (${latest})`, "run `lecodes app update`", false)
730
+ } catch { /* offline — not a preflight failure */ }
731
+ }
732
+
733
+ const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
734
+ const wanted = Object.keys(config.plugins ?? {})
735
+ const inSync = wanted.every(id => lock[id]) && Object.keys(lock).every(id => wanted.includes(id))
736
+ check(wanted.length === 0 || inSync, "Plugins synced", "run `lecodes app sync`")
737
+
738
+ // The pbxproj references App/App.entitlements → the file must exist or signing fails.
739
+ const pbxFile = join(root, "ios", "App.xcodeproj", "project.pbxproj")
740
+ if (existsSync(pbxFile) && readFileSync(pbxFile, "utf8").includes("CODE_SIGN_ENTITLEMENTS")) {
741
+ check(existsSync(join(root, "ios", "App", "App.entitlements")), "App/App.entitlements present", "run `lecodes app sync`")
742
+ }
743
+
744
+ // Push registration is identity-gated: the synced runtime must carry the baked uuid
745
+ // (server-backed project + lecodes-ios-sdk ≥ SDK_IDENTITY_VERSION + a sync since).
746
+ if (wanted.includes("push")) {
747
+ const runtimeSwift = join(root, "ios", "LeCodesRuntime", "Sources", "LeCodesRuntime", "LeCodesRuntime.swift")
748
+ const baked = existsSync(runtimeSwift) && readFileSync(runtimeSwift, "utf8").includes("bootProjectUuid")
749
+ check(baked, "Push world identity baked",
750
+ `without it Push.register() rejects "unavailable" — needs a pushed project (uuid), lecodes-ios-sdk ${SDK_IDENTITY_VERSION}+ and \`lecodes app sync\``, false)
751
+ }
752
+
753
+ if (config.update) {
754
+ const plist = readFileSync(join(root, "ios", "App", "Info.plist"), "utf8")
755
+ check(plist.includes("<key>LeCodesUpdateURL</key>"), "OTA update URL in Info.plist", "run `lecodes app sync`", false)
756
+ // The plist key is necessary but not sufficient: the user-owned ViewController must route
757
+ // the launch through LeCodesUpdater. Shells generated before OTA existed read Bundle.main
758
+ // directly — downloads land but never run, and sync can't rewrite App/*.swift. Doctor is
759
+ // the only place that can catch it.
760
+ const appDir = join(root, "ios", "App")
761
+ const callsUpdater = readdirSync(appDir).some(f =>
762
+ f.endsWith(".swift") && readFileSync(join(appDir, f), "utf8").includes("LeCodesUpdater.activeBundle"))
763
+ check(callsUpdater, "App code launches via LeCodesUpdater",
764
+ `no App/*.swift calls LeCodesUpdater.activeBundle() — downloaded updates would never run; see "Over-the-air updates" in ios/README.md for the ViewController snippet`, false)
765
+ }
766
+
767
+ const asc = readJson<{ keyPath?: string }>(join(homedir(), ".lecodes", "asc.json"))
768
+ check(!!asc?.keyPath, "App Store Connect API key configured", "pass --key-path/--key-id/--issuer-id to `lecodes app upload` once to store it", false)
769
+ }
770
+
771
+ const ARCHIVE_PATH = "ios/build/App.xcarchive"
772
+
773
+ const appBuild = async (args: Args) => {
774
+ const platform = args._[1]
775
+ if (platform === "android") return buildAndroid(args)
776
+ if (platform === "desktop") return buildDesktop(args)
777
+ if (platform !== undefined && platform !== "ios") {
778
+ throw new CliError(`Unknown platform "${platform}" — use ios, android or desktop.`)
779
+ }
780
+ const { root, config } = requireShell()
781
+ const configuration = flagStr(args, "configuration") ?? "Release"
782
+ const method = flagStr(args, "method") ?? "app-store-connect"
783
+
784
+ if (!flagBool(args, "no-sync")) await appSync({ _: ["sync", "ios"], flags: {} })
785
+
786
+ note(`Archiving (${configuration})… this takes a few minutes`)
787
+ runTool(root, "xcodebuild", [
788
+ "-project", "ios/App.xcodeproj", "-scheme", "App",
789
+ "-destination", "generic/platform=iOS", "-configuration", configuration,
790
+ "archive", "-archivePath", ARCHIVE_PATH, "-allowProvisioningUpdates",
791
+ ])
792
+ success(`Archive: ios/build/App.xcarchive`)
793
+
794
+ if (flagBool(args, "no-export")) return
795
+ const optionsPath = join(root, "ios", "build", "ExportOptions.plist")
796
+ writeFileSync(optionsPath, exportOptionsPlist(method, config.ios!.teamId ?? ""))
797
+ note(`Exporting (.ipa, method ${method})…`)
798
+ runTool(root, "xcodebuild", [
799
+ "-exportArchive", "-archivePath", ARCHIVE_PATH,
800
+ "-exportOptionsPlist", optionsPath, "-exportPath", "ios/build/export",
801
+ "-allowProvisioningUpdates",
802
+ ])
803
+ success("Export: ios/build/export/ — upload with `lecodes app upload ios`")
804
+ }
805
+
806
+ const appUpload = async (args: Args) => {
807
+ if (args._[1] === "android") {
808
+ throw new CliError("Play upload isn't wired yet — upload android/app/build/outputs/bundle/release/*.aab in the Play Console (`lecodes app build android` produces it).")
809
+ }
810
+ const { root, config } = requireShell()
811
+ if (!existsSync(join(root, ARCHIVE_PATH))) {
812
+ throw new CliError("No archive at ios/build/App.xcarchive — run `lecodes app build ios` first.")
813
+ }
814
+
815
+ // App Store Connect API key: flags on first use, then ~/.lecodes/asc.json.
816
+ const ascPath = join(homedir(), ".lecodes", "asc.json")
817
+ const stored = readJson<{ keyPath?: string, keyId?: string, issuerId?: string }>(ascPath) ?? {}
818
+ const keyPath = flagStr(args, "key-path") ?? stored.keyPath
819
+ const keyId = flagStr(args, "key-id") ?? stored.keyId
820
+ const issuerId = flagStr(args, "issuer-id") ?? stored.issuerId
821
+ if (!keyPath || !keyId || !issuerId) {
822
+ throw new CliError("App Store Connect API key needed: --key-path <AuthKey.p8> --key-id <id> --issuer-id <id>\n(create one at appstoreconnect.apple.com → Users and Access → Integrations; stored in ~/.lecodes/asc.json after first use)")
823
+ }
824
+ if (!existsSync(keyPath)) throw new CliError(`API key file not found: ${keyPath}`)
825
+ if (flagStr(args, "key-path")) {
826
+ mkdirSync(dirname(ascPath), { recursive: true })
827
+ writeJson(ascPath, { keyPath, keyId, issuerId })
828
+ note("Stored the key reference in ~/.lecodes/asc.json")
829
+ }
830
+
831
+ const optionsPath = join(root, "ios", "build", "UploadOptions.plist")
832
+ writeFileSync(optionsPath, exportOptionsPlist("app-store-connect", config.ios!.teamId ?? "", "upload"))
833
+ note("Uploading to App Store Connect… (processing continues on Apple's side)")
834
+ runTool(root, "xcodebuild", [
835
+ "-exportArchive", "-archivePath", ARCHIVE_PATH,
836
+ "-exportOptionsPlist", optionsPath, "-exportPath", "ios/build/upload",
837
+ "-authenticationKeyPath", resolve(keyPath),
838
+ "-authenticationKeyID", keyId,
839
+ "-authenticationKeyIssuerID", issuerId,
840
+ "-allowProvisioningUpdates",
841
+ ])
842
+
843
+ // A successful upload consumes this build number — bump for the next one.
844
+ const configPath = join(root, "app.json")
845
+ const fresh = readJson<AppConfig>(configPath)!
846
+ fresh.ios!.buildNumber = (fresh.ios!.buildNumber ?? 1) + 1
847
+ writeJson(configPath, fresh)
848
+ success(`Uploaded build ${(fresh.ios!.buildNumber ?? 2) - 1} — check App Store Connect / TestFlight once processing finishes.`)
849
+ note(`ios.buildNumber bumped to ${fresh.ios!.buildNumber} for the next build.`)
850
+ }
851
+
852
+ /* --------------------------------------- open ---------------------------------------- */
853
+
854
+ /** `lecodes app open [ios|android]` — open the native shell in its IDE (Xcode / Android
855
+ * Studio). No platform: open whichever single shell exists. */
856
+ const appOpen = async (args: Args) => {
857
+ const root = projectRoot()
858
+ let platform = args._[1]
859
+ if (!platform) {
860
+ const hasIos = existsSync(join(root, "ios"))
861
+ const hasAndroid = existsSync(join(root, "android"))
862
+ if (hasIos && hasAndroid) throw new CliError("Both shells exist — `lecodes app open ios` or `lecodes app open android`.")
863
+ platform = hasAndroid ? "android" : "ios"
864
+ }
865
+
866
+ if (platform === "ios") {
867
+ const proj = join(root, "ios", "App.xcodeproj")
868
+ if (!existsSync(proj)) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
869
+ if (process.platform !== "darwin") throw new CliError("Opening Xcode requires macOS.")
870
+ execFileSync("open", [proj], { stdio: "ignore" })
871
+ success("Opened ios/App.xcodeproj in Xcode.")
872
+ return
873
+ }
874
+ if (platform === "android") return openAndroid()
875
+ throw new CliError(`Unknown platform "${platform}" — use ios or android.`)
876
+ }
877
+
878
+ /* --------------------------------------- entry --------------------------------------- */
879
+
880
+ export const app = async (args: Args) => {
881
+ const sub = args._[0]
882
+ if (sub === "init") return appInit(args)
883
+ if (sub === "sync") return appSync(args)
884
+ if (sub === "update") return appUpdate(args)
885
+ if (sub === "plugin") return appPlugin(args)
886
+ if (sub === "doctor") return appDoctor(args)
887
+ if (sub === "build") return appBuild(args)
888
+ if (sub === "upload") return appUpload(args)
889
+ if (sub === "open") return appOpen(args)
890
+ throw new CliError("Usage: lecodes app <init ios|android|desktop | sync | update | open | plugin list|add|remove | doctor | build ios|android|desktop | upload ios> — see `lecodes help`.")
891
+ }