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,532 @@
1
+ import { execFileSync, spawn, spawnSync } from "node:child_process"
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { homedir, tmpdir } from "node:os"
4
+ import { join, resolve } from "node:path"
5
+ import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
6
+ import { CliError, note, warnErr } from "./util"
7
+ import { makeStackDemangler } from "./compile/nativeStack"
8
+
9
+ /*
10
+ * The NATIVE renderer behind `lecodes render --desktop`: the LeCodes Desktop host (tgfx UI + Filament
11
+ * 3D + creator-2d, composited) run headless — CREATOR_HIDDEN + CREATOR_WINDOW_SIZE +
12
+ * CREATOR_MAX_FRAMES + CREATOR_DUMP_PNG (the host dumps only the final frame of a bounded run).
13
+ *
14
+ * The binary is NOT shipped with the CLI (it's ~23 MB per platform and most users never render
15
+ * natively). It downloads on first use from the same S3 release bucket the desktop distribution
16
+ * publishes to (scripts/release/* → releases/versions.json), sha256-verified, into
17
+ * ~/.lecodes/renderer/desktop-<version>/. LECODES_DESKTOP_EXE overrides resolution entirely
18
+ * (local dev: point it at packages/desktop/build/lecodes-desktop.exe).
19
+ *
20
+ * Platforms: windows-x64 + linux (the ubuntu build) + macos-arm64. The macOS host is built and
21
+ * published separately (the Apple repo's LeCodes Desktop.app, via scripts/release/release-macos.sh)
22
+ * as a notarized DMG under the same "desktop" product — so its version line has gaps: a release
23
+ * may ship for Windows only. On darwin the cache therefore holds the newest published version
24
+ * that HAS a macOS artifact (never below the pin), and an app installed in /Applications is only
25
+ * the fallback for when the manifest can't be reached. The headless render contract
26
+ * (CREATOR_HIDDEN & co) is still Windows/Linux-only — the macOS host has no hidden mode.
27
+ */
28
+
29
+ /** The MINIMUM desktop version this CLI drives. The headless-render envs (CREATOR_HIDDEN,
30
+ * CREATOR_WINDOW_SIZE, final-frame-only CREATOR_DUMP_PNG) shipped in 1.1.0 — an older host would
31
+ * ignore the size, flash a window and dump every frame, so we require the pin rather than degrade.
32
+ * Bump together with desktop releases that change the render contract. `lecodes desktop update`
33
+ * can move FORWARD past it (selection saved in ~/.lecodes/renderer/current.json); it never goes below. */
34
+ export const DESKTOP_RENDERER_VERSION = "1.1.0"
35
+
36
+ // The manifest fetch + sha256-verified archive download live in releases.ts (shared with the
37
+ // matc tool). semverNewer is re-exported for the existing importers (commands/desktop.ts).
38
+ export { semverNewer }
39
+
40
+ /** Which desktop host to run. "gl" is the default everywhere; "vulkan" is a separate Windows binary
41
+ * (Filament + tgfx on one VkDevice), NOT a runtime switch — it has its own artifact, its own
42
+ * shaders, and a smaller feature set (no 2D engine, no native-view plugins). Chosen per project via
43
+ * app.json `desktop.renderer`, or per run via `--renderer` / LECODES_DESKTOP_RENDERER. */
44
+ export type DesktopRenderer = "gl" | "vulkan"
45
+
46
+ export const DESKTOP_RENDERER_MIN_VERSION: Record<DesktopRenderer, string> = { gl: "0.0.0", vulkan: "1.4.0" }
47
+
48
+ const platformKey = (renderer: DesktopRenderer = "gl"): string => {
49
+ if (process.platform === "win32") return renderer === "vulkan" ? "windows-x64-vulkan" : "windows-x64"
50
+ if (renderer === "vulkan") {
51
+ throw new CliError(`The Vulkan desktop host is Windows-only (this is ${process.platform}). Use the default renderer.`)
52
+ }
53
+ if (process.platform === "linux") return "ubuntu-linux-x64"
54
+ if (process.platform === "darwin") return process.arch === "arm64" ? "macos-arm64" : "macos-x64"
55
+ throw new CliError(`The LeCodes Desktop host isn't available on ${process.platform}.`)
56
+ }
57
+
58
+ const isMac = () => process.platform === "darwin"
59
+
60
+ /** The host binary's file name. macOS: the executable inside LeCodes Desktop.app. */
61
+ const exeName = () => (process.platform === "win32" ? "lecodes-desktop.exe" : isMac() ? "lecodes-app" : "lecodes-desktop")
62
+
63
+ /** Recursively locate the host binary inside an extracted archive (the zip nests a
64
+ * `lecodes-desktop/` top-level folder; the tarballs may differ — search, don't assume). */
65
+ const findExe = (dir: string): string | null => {
66
+ const name = exeName()
67
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
68
+ const p = join(dir, entry.name)
69
+ if (entry.isFile() && entry.name === name) return p
70
+ if (entry.isDirectory()) { const found = findExe(p); if (found) return found }
71
+ }
72
+ return null
73
+ }
74
+
75
+ const cacheRoot = () => join(homedir(), ".lecodes", "renderer")
76
+ const currentFile = () => join(cacheRoot(), "current.json")
77
+
78
+ /** Versions with a working host binary in the cache, newest first. */
79
+ export const installedDesktopVersions = (): string[] => {
80
+ try {
81
+ return readdirSync(cacheRoot(), { withFileTypes: true })
82
+ .filter((e) => e.isDirectory() && e.name.startsWith("desktop-"))
83
+ .map((e) => e.name.slice("desktop-".length))
84
+ .filter((v) => findExe(join(cacheRoot(), `desktop-${v}`)) !== null)
85
+ .sort(semverNewer)
86
+ } catch {
87
+ return []
88
+ }
89
+ }
90
+
91
+ /** The version `--desktop` renders with: the `lecodes desktop update` selection when it's ahead of
92
+ * the CLI's pin, the pin otherwise (a CLI upgrade that raises the contract wins over a stale save). */
93
+ export const selectedDesktopVersion = (): string => {
94
+ try {
95
+ const saved = (JSON.parse(readFileSync(currentFile(), "utf8")) as { version?: string }).version
96
+ if (saved && /^\d+\.\d+\.\d+/.test(saved) && semverNewer(DESKTOP_RENDERER_VERSION, saved) > 0) return saved
97
+ } catch { /* no saved selection */ }
98
+ return DESKTOP_RENDERER_VERSION
99
+ }
100
+
101
+ /** The desktop product from the release manifest (always fetched fresh — see releases.ts). */
102
+ export const fetchDesktopManifest = (): Promise<{ latest: string, versions: ManifestVersion[] }> =>
103
+ fetchReleaseProduct("desktop", "desktop")
104
+
105
+ /** The version a Mac actually gets for a wanted one: the same version when it has a macOS
106
+ * artifact, else the newest published one that does (never below the CLI's pin — the render
107
+ * contract) — null when nothing qualifies. Pure; see the platform note in the header. */
108
+ export const pickMacVersion = (versions: ManifestVersion[], wanted: string, key = "macos-arm64"): string | null => {
109
+ if (versions.find((v) => v.version === wanted)?.files[key]) return wanted
110
+ const eligible = versions
111
+ .filter((v) => v.files[key] && semverNewer(DESKTOP_RENDERER_VERSION, v.version) >= 0)
112
+ .map((v) => v.version)
113
+ .sort(semverNewer)
114
+ return eligible[0] ?? null
115
+ }
116
+
117
+ /** Unpack a DMG: mount read-only, copy the .app out, unmount — the copy is a plain folder in the
118
+ * cache with no quarantine flag (it never went through a browser), like every other host. */
119
+ const extractDmg = (archive: string, stage: string) => {
120
+ const mount = join(stage, "mnt")
121
+ mkdirSync(mount, { recursive: true })
122
+ execFileSync("hdiutil", ["attach", "-nobrowse", "-readonly", "-quiet", "-mountpoint", mount, archive])
123
+ try {
124
+ const app = readdirSync(mount).find((n) => n.endsWith(".app"))
125
+ if (!app) throw new CliError("The downloaded DMG holds no .app bundle.")
126
+ execFileSync("cp", ["-R", join(mount, app), join(stage, app)])
127
+ } finally {
128
+ spawnSync("hdiutil", ["detach", "-quiet", "-force", mount])
129
+ rmSync(mount, { recursive: true, force: true })
130
+ }
131
+ }
132
+
133
+ const downloadDesktop = async (version: string, dest: string, renderer: DesktopRenderer = "gl"): Promise<void> => {
134
+ const key = platformKey(renderer)
135
+ const entry = (await fetchDesktopManifest()).versions.find((v) => v.version === version)
136
+ const file = entry?.files[key]
137
+ if (!file) {
138
+ // Never silently fall back to the GL host: a project asking for Vulkan and quietly getting
139
+ // OpenGL makes "why is it slow / why does the shader look wrong" unanswerable.
140
+ if (renderer === "vulkan") {
141
+ throw new CliError(
142
+ `The Vulkan desktop host isn't published for LeCodes Desktop ${version} [${key}].\n` +
143
+ ` Vulkan needs desktop >= ${DESKTOP_RENDERER_MIN_VERSION.vulkan}; run \`lecodes desktop update\`, ` +
144
+ `set LECODES_DESKTOP_EXE to a locally built host, or drop desktop.renderer from app.json.`,
145
+ )
146
+ }
147
+ throw new CliError(
148
+ `LeCodes Desktop ${version} [${key}] isn't published yet — \`--desktop\` needs it for the ` +
149
+ `headless render contract.\nSet LECODES_DESKTOP_EXE to a locally built host, or update lecodes-cli.`,
150
+ )
151
+ }
152
+
153
+ note(`Downloading LeCodes Desktop ${version} [${key}] (${(file.size / 1024 / 1024).toFixed(1)} MB)…`)
154
+ await downloadReleaseArchive(file, {
155
+ cacheRoot: cacheRoot(),
156
+ dest,
157
+ validate: (dir) => (findExe(dir) ? null : "The downloaded archive doesn't contain the desktop host binary."),
158
+ extract: file.url.toLowerCase().endsWith(".dmg") ? extractDmg : undefined,
159
+ })
160
+ }
161
+
162
+ /** A macOS host installed by hand (/Applications or ~/Applications) — the OFFLINE fallback when
163
+ * nothing is cached and the manifest can't be fetched; the cache is preferred because its
164
+ * version is known and chosen by the CLI, an installed app is whatever the user last dragged in. */
165
+ export const findMacHostExe = (): string | null => {
166
+ const candidates = [
167
+ ...["/Applications", join(homedir(), "Applications")]
168
+ .flatMap((root) => ["LeCodes Desktop.app", "LeCodes.app"].map((app) => join(root, app))),
169
+ ].map((app) => join(app, "Contents", "MacOS", "lecodes-app"))
170
+ return candidates.find((exe) => existsSync(exe)) ?? null
171
+ }
172
+
173
+ /** macOS: the cached host for the selected version, else the newest published version with a
174
+ * macOS artifact (downloaded + remembered as the selection, so later runs are offline-stable),
175
+ * else the installed app. */
176
+ const resolveMacExe = async (selected: string): Promise<string> => {
177
+ const cached = findExe(join(cacheRoot(), `desktop-${selected}`))
178
+ if (cached) return cached
179
+ try {
180
+ const { versions } = await fetchDesktopManifest()
181
+ const version = pickMacVersion(versions, selected, platformKey())
182
+ if (!version) {
183
+ throw new CliError(`No LeCodes Desktop release for ${platformKey()} is published at or above ${DESKTOP_RENDERER_VERSION}.`)
184
+ }
185
+ const dir = join(cacheRoot(), `desktop-${version}`)
186
+ let exe = findExe(dir)
187
+ if (!exe) {
188
+ await downloadDesktop(version, dir)
189
+ exe = findExe(dir)
190
+ if (!exe) throw new CliError(`Renderer cache at ${dir} is broken — delete the folder and retry.`)
191
+ }
192
+ if (version !== selected) {
193
+ note(`LeCodes Desktop ${selected} has no macOS build — using ${version}.`)
194
+ mkdirSync(cacheRoot(), { recursive: true })
195
+ writeFileSync(currentFile(), JSON.stringify({ version }, null, 2))
196
+ }
197
+ return exe
198
+ } catch (e) {
199
+ const installed = findMacHostExe()
200
+ if (installed) {
201
+ warnErr(`${e instanceof Error ? e.message : String(e)}\n Falling back to the installed host: ${installed}`)
202
+ return installed
203
+ }
204
+ throw new CliError(
205
+ `${e instanceof Error ? e.message : String(e)}\n` +
206
+ " No LeCodes Desktop.app is installed either — retry online (`lecodes desktop update`), install the DMG into\n" +
207
+ " /Applications, or point LECODES_DESKTOP_EXE at a build (…/LeCodes Desktop.app/Contents/MacOS/lecodes-app).",
208
+ )
209
+ }
210
+ }
211
+
212
+ /** The desktop host binary: LECODES_DESKTOP_EXE override → the selected version's cache → download.
213
+ * The two renderers cache side by side (`desktop-<version>` / `desktop-<version>-vulkan`) so
214
+ * switching costs nothing after the first download. LECODES_DESKTOP_EXE wins over the renderer —
215
+ * it names an exact binary, and which backend that binary uses is its own business. */
216
+ export const resolveDesktopExe = async (renderer: DesktopRenderer = "gl"): Promise<string> => {
217
+ const override = process.env.LECODES_DESKTOP_EXE
218
+ if (override) {
219
+ if (!existsSync(override)) throw new CliError(`LECODES_DESKTOP_EXE points at "${override}" which doesn't exist.`)
220
+ return resolve(override)
221
+ }
222
+ if (renderer === "vulkan") platformKey(renderer) // fail early + clearly on a non-Windows machine
223
+ const version = selectedDesktopVersion()
224
+ // macOS: the same cache, fed from the DMG the Apple repo publishes; the version may differ from
225
+ // the selection (gaps in the macOS line) and an installed app is the offline fallback.
226
+ if (isMac()) return resolveMacExe(version)
227
+ const dir = join(cacheRoot(), `desktop-${version}${renderer === "vulkan" ? "-vulkan" : ""}`)
228
+ let exe = existsSync(dir) ? findExe(dir) : null
229
+ if (!exe) {
230
+ await downloadDesktop(version, dir, renderer)
231
+ exe = findExe(dir)
232
+ if (!exe) throw new CliError(`Renderer cache at ${dir} is broken — delete the folder and retry.`)
233
+ }
234
+ return exe
235
+ }
236
+
237
+ /** `lecodes desktop update`: move the render host to the newest published release (never below the
238
+ * CLI's pin — a manifest that somehow regressed under it is an error, not a downgrade). Saves the
239
+ * selection so later `render --desktop` runs use it; the download is skipped when already cached. */
240
+ export const updateDesktop = async (): Promise<void> => {
241
+ const manifest = await fetchDesktopManifest()
242
+ // macOS: "latest" is the latest WITH a macOS artifact — a Windows-only release isn't an update here.
243
+ const latest = isMac() ? pickMacVersion(manifest.versions, manifest.latest, platformKey()) : manifest.latest
244
+ if (!latest) throw new CliError(`No LeCodes Desktop release for ${platformKey()} is published at or above ${DESKTOP_RENDERER_VERSION}.`)
245
+ if (semverNewer(latest, DESKTOP_RENDERER_VERSION) > 0) {
246
+ throw new CliError(
247
+ `The newest published LeCodes Desktop is ${latest}, but this CLI needs ≥ ${DESKTOP_RENDERER_VERSION} ` +
248
+ `for the render contract. Publish the desktop release first.`,
249
+ )
250
+ }
251
+ const before = selectedDesktopVersion()
252
+ const dir = join(cacheRoot(), `desktop-${latest}`)
253
+ // "Up to date" means the cache holds the artifact the manifest CURRENTLY names, not merely a dir
254
+ // with the right version in its name: a release can be republished under the same number (the
255
+ // macOS host was, as 1.3.2), and a stale cache then silently outlives it. Comparing the recorded
256
+ // sha256 catches that; a dir with no stamp predates the check, so its contents are unknown and it
257
+ // is replaced too. Only `update` pays for this — it already has the manifest in hand.
258
+ const published = (await fetchDesktopManifest()).versions.find((v) => v.version === latest)?.files[platformKey()]
259
+ const cached = existsSync(dir) && findExe(dir) !== null
260
+ const republished = cached && published !== undefined && cachedArtifactSha(dir) !== published.sha256
261
+ if (republished) {
262
+ note(`LeCodes Desktop ${latest} was republished since this machine cached it — downloading the current build.`)
263
+ rmSync(dir, { recursive: true, force: true })
264
+ }
265
+ const hadIt = cached && !republished
266
+ if (!hadIt) await downloadDesktop(latest, dir)
267
+ mkdirSync(cacheRoot(), { recursive: true })
268
+ writeFileSync(currentFile(), JSON.stringify({ version: latest }, null, 2))
269
+ if (latest === before && hadIt) note(`Already up to date: LeCodes Desktop ${latest}.`)
270
+ else if (latest === before) note(`Re-downloaded LeCodes Desktop ${latest}.`)
271
+ else note(`Desktop renderer set to ${latest} (was ${before}).`)
272
+ }
273
+
274
+ /** Window config forwarded to the host as CREATOR_* envs (unknown envs are ignored by older
275
+ * hosts, so none of these bump the version pin — they just don't apply there). Sizes are
276
+ * LOGICAL units; the host multiplies by the display scale. */
277
+ export type DesktopWindowOpts = {
278
+ /** Which host binary to run (see DesktopRenderer). Must match the renderer the bundle's shaders
279
+ * were staged for — resolve it once per command and pass the same value to both. */
280
+ renderer?: DesktopRenderer
281
+ width?: number
282
+ height?: number
283
+ minWidth?: number
284
+ minHeight?: number
285
+ resizable?: boolean
286
+ title?: string
287
+ /** Borderless fullscreen on the primary monitor (host ≥1.3.0; older hosts ignore the env). */
288
+ fullscreen?: boolean
289
+ /** Display scale: a fixed density (0.5–4) or "fit" (derive from design frame vs monitor/work
290
+ * area; host ≥1.3.0 — a 1.2.0 host warns and ignores "fit"). */
291
+ scale?: number | "fit"
292
+ /** 3D buffer scale vs the window, 0.25–1 (host ≥1.4.0); the UI stays at native pixels. */
293
+ renderScale?: number
294
+ /** 3D buffer cap [W, H] in px (host ≥1.4.0) — e.g. [1920, 1080] keeps a 4K fullscreen at 1080p 3D. */
295
+ maxRenderSize?: [number, number]
296
+ /** Draw the host's frame counter in the corner (host ≥1.5.0; older hosts ignore the env). Costs
297
+ * nothing to leave off and next to nothing on — the digits are rectangles, no UI behind them. */
298
+ fps?: boolean
299
+ /** Multiplayer launch facts for the host (`--server --port N --max-clients N` / `--connect ip:port`);
300
+ * the SDK reads them through `Net.launch` and decides (docs/multiplayer-plan.md). */
301
+ net?: { role: "server" | "client"; address?: string; port?: number; maxClients?: number }
302
+ }
303
+
304
+ const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
305
+ const env: Record<string, string> = {}
306
+ // A lone width/height fills the other side from the host default so it isn't silently ignored.
307
+ const width = opts.width ?? (opts.height !== undefined ? 1280 : undefined)
308
+ const height = opts.height ?? (opts.width !== undefined ? 720 : undefined)
309
+ if (width && height) env.CREATOR_WINDOW_SIZE = `${width}x${height}`
310
+ if (opts.minWidth && opts.minHeight) env.CREATOR_MIN_WINDOW_SIZE = `${opts.minWidth}x${opts.minHeight}`
311
+ if (opts.title) env.CREATOR_WINDOW_TITLE = opts.title
312
+ if (opts.resizable === false) env.CREATOR_WINDOW_FIXED = "1"
313
+ if (opts.fullscreen !== undefined) env.CREATOR_WINDOW_FULLSCREEN = opts.fullscreen ? "1" : "0"
314
+ if (opts.scale === "fit") env.CREATOR_UI_SCALE = "fit"
315
+ else if (typeof opts.scale === "number") env.CREATOR_UI_SCALE = String(opts.scale)
316
+ if (typeof opts.renderScale === "number") env.CREATOR_RENDER_SCALE = String(opts.renderScale)
317
+ if (opts.maxRenderSize && opts.maxRenderSize[0] > 0 && opts.maxRenderSize[1] > 0) {
318
+ env.CREATOR_RENDER_MAX = `${Math.round(opts.maxRenderSize[0])}x${Math.round(opts.maxRenderSize[1])}`
319
+ }
320
+ if (opts.fps) env.CREATOR_FPS = "1"
321
+ return env
322
+ }
323
+
324
+ // The host's own multiplayer flags (main.cpp argv) — options, not env, so a shipped exe takes them too.
325
+ const netArgs = (net: DesktopWindowOpts["net"]): string[] => {
326
+ if (!net) return []
327
+ const args: string[] = []
328
+ if (net.role === "server") args.push("--server")
329
+ else if (net.address) args.push("--connect", net.address)
330
+ if (net.port) args.push("--port", String(net.port))
331
+ if (net.maxClients) args.push("--max-clients", String(net.maxClients))
332
+ return args
333
+ }
334
+
335
+ export type DesktopRenderOptions = {
336
+ /** Called per output chunk; returning true ends the run early (the host is killed, the
337
+ * outcome is a success with the output so far — a bake that printed its done marker). */
338
+ stopOnLine?: (chunk: string) => boolean
339
+ /** Which host binary to run (see DesktopRenderer). Must match the renderer the bundle's shaders
340
+ * were staged for — resolve it once per command and pass the same value to both. */
341
+ renderer?: DesktopRenderer
342
+ width: number
343
+ height: number
344
+ /** Frames to run before the dump — headroom for async loads (GLB, images, fonts). */
345
+ frames: number
346
+ /** Absolute or cwd-relative path the PNG lands at. */
347
+ outPng: string
348
+ /** Forward the host's stdout/stderr (app console.log included) to stderr. */
349
+ logs: boolean
350
+ timeoutMs: number
351
+ /** CREATOR_SCRIPT contents (the line format in packages/desktop/src/main.cpp `scriptStep`): the
352
+ * host drives these commands and self-terminates when done; `frames` is then only a safety cap. */
353
+ script?: string
354
+ /** CREATOR_FIXED_DT: virtual app clock, ms per frame (0/undefined = wall clock). */
355
+ fixedDtMs?: number
356
+ /** Inspection camera: CREATOR_CAMERA ("ex,ey,ez;tx,ty,tz[;fov]") or CREATOR_VIEW ("iso[;fov]"). */
357
+ camera?: string
358
+ view?: string
359
+ }
360
+
361
+ export type DesktopRenderOutcome = {
362
+ /** Everything the host printed (stdout + stderr), for marker parsing / expect.log. */
363
+ output: string
364
+ exitCode: number | null
365
+ }
366
+
367
+ /** Stage a compiled bundle for the host: app.js + the local-asset resources copied next to it
368
+ * (the host's fetchLocal resolves them by basename relative to its cwd). Caller removes the dir. */
369
+ const stageBundle = (js: string, resources: { name: string, absPath?: string }[]): string => {
370
+ const runDir = mkdtempSync(join(tmpdir(), "lecodes-render-"))
371
+ writeFileSync(join(runDir, "app.js"), js)
372
+ for (const r of resources) {
373
+ if (r.absPath && existsSync(r.absPath) && statSync(r.absPath).isFile()) {
374
+ copyFileSync(r.absPath, join(runDir, r.name))
375
+ }
376
+ }
377
+ return runDir
378
+ }
379
+
380
+ /** Forward a child stream line-by-line through `map`. Line-buffered so a chunk boundary can never
381
+ * cut a `module.js:L:C` frame in half; the trailing partial line is flushed when the stream ends. */
382
+ const pipeThroughDemangler = (src: NodeJS.ReadableStream, dst: NodeJS.WritableStream, map: (s: string) => string) => {
383
+ let rest = ""
384
+ src.on("data", (d: Buffer | string) => {
385
+ rest += String(d)
386
+ const nl = rest.lastIndexOf("\n")
387
+ if (nl < 0) return
388
+ dst.write(map(rest.slice(0, nl + 1)))
389
+ rest = rest.slice(nl + 1)
390
+ })
391
+ src.on("end", () => { if (rest) dst.write(map(rest)) })
392
+ }
393
+
394
+ /** `lecodes desktop run`: open the compiled project in a normal (visible, interactive) desktop
395
+ * window and stream the host's output — the plain-run counterpart of `lecodes dev`, no reload.
396
+ * Resolves until the window is closed. Ambient CREATOR_* envs pass through (handy for testing). */
397
+ export const runDesktopApp = async (
398
+ js: string,
399
+ resources: { name: string, absPath?: string }[],
400
+ opts: DesktopWindowOpts = {},
401
+ ): Promise<void> => {
402
+ const exe = await resolveDesktopExe(opts.renderer)
403
+ const runDir = stageBundle(js, resources)
404
+ try {
405
+ const env = { ...process.env, ...windowEnv(opts) }
406
+ const demangle = makeStackDemangler(js)
407
+ const argv = [join(runDir, "app.js"), ...netArgs(opts.net)]
408
+ await new Promise<void>((done, fail) => {
409
+ // Piped (not inherited) only when there's a map to apply — the host's frames name the minified
410
+ // bundle (`module.js:5:51631`) and we rewrite them to project positions on the way through.
411
+ const child = demangle
412
+ ? spawn(exe, argv, { cwd: runDir, env, stdio: ["ignore", "pipe", "pipe"] })
413
+ : spawn(exe, argv, { cwd: runDir, env, stdio: ["ignore", "inherit", "inherit"] })
414
+ if (demangle) {
415
+ pipeThroughDemangler(child.stdout!, process.stdout, demangle)
416
+ pipeThroughDemangler(child.stderr!, process.stderr, demangle)
417
+ }
418
+ child.on("error", (e) => fail(new CliError(`Can't run the desktop host: ${e.message}`)))
419
+ child.on("exit", (code) => {
420
+ if (code === 0 || code === null) done() // null: killed (e.g. Ctrl+C reached the child)
421
+ else fail(new CliError(`The desktop host exited with code ${code}.`))
422
+ })
423
+ })
424
+ } finally {
425
+ // A host that was just killed (timeout / stopOnLine) can still hold the staging dir for a moment;
426
+ // an EPERM here must not replace the real error with "Permission denied" (seen on a 10-minute
427
+ // lightmap timeout, read as a crash for two hours). Retry briefly, then leave it to the OS temp.
428
+ for (let attempt = 0; ; attempt++) {
429
+ try { rmSync(runDir, { recursive: true, force: true }); break }
430
+ catch {
431
+ if (attempt >= 5) break
432
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200)
433
+ }
434
+ }
435
+ }
436
+ }
437
+
438
+ /** `lecodes dev --desktop`: open the dev-server URL in the host — its launcher fetches the
439
+ * bundle and the appended prelude hot-reloads over websocket. No staging dir: assets stream
440
+ * from the dev server. Resolves when the window closes (any exit code — a dev host getting
441
+ * killed mid-session is not an error); the caller's dev server keeps running. stdout is
442
+ * dropped: the prelude already mirrors app console output to the dev terminal. */
443
+ export const runDesktopDev = async (url: string, opts: DesktopWindowOpts = {}): Promise<void> => {
444
+ const exe = await resolveDesktopExe(opts.renderer)
445
+ const env = { ...process.env, ...windowEnv(opts) }
446
+ await new Promise<void>((done, fail) => {
447
+ const child = spawn(exe, [url], { env, stdio: ["ignore", "ignore", "inherit"] })
448
+ child.on("error", (e) => fail(new CliError(`Can't run the desktop host: ${e.message}`)))
449
+ child.on("exit", () => done())
450
+ })
451
+ }
452
+
453
+ /** Run a compiled bundle in the hidden desktop host and dump the final frame as a PNG. */
454
+ export const runDesktopRender = async (
455
+ js: string,
456
+ resources: { name: string, absPath?: string }[],
457
+ opts: DesktopRenderOptions,
458
+ ): Promise<DesktopRenderOutcome> => {
459
+ if (isMac()) {
460
+ throw new CliError(
461
+ "`lecodes render --desktop` isn't available on macOS yet — the macOS host has no headless mode " +
462
+ "(CREATOR_HIDDEN / CREATOR_DUMP_PNG). `lecodes desktop run` and `lecodes desktop build` do work there.",
463
+ )
464
+ }
465
+ const exe = await resolveDesktopExe(opts.renderer)
466
+ const runDir = stageBundle(js, resources)
467
+ try {
468
+ const appJs = join(runDir, "app.js")
469
+ const outAbs = resolve(opts.outPng)
470
+ const env: Record<string, string | undefined> = {
471
+ ...process.env,
472
+ CREATOR_DUMP_PNG: outAbs,
473
+ CREATOR_MAX_FRAMES: String(opts.frames),
474
+ CREATOR_WINDOW_SIZE: `${opts.width}x${opts.height}`,
475
+ CREATOR_HIDDEN: "1",
476
+ }
477
+ if (opts.script !== undefined) {
478
+ const scriptPath = join(runDir, "script.txt")
479
+ writeFileSync(scriptPath, opts.script)
480
+ env.CREATOR_SCRIPT = scriptPath
481
+ }
482
+ if (opts.fixedDtMs) env.CREATOR_FIXED_DT = String(opts.fixedDtMs)
483
+ if (opts.camera) env.CREATOR_CAMERA = opts.camera
484
+ if (opts.view) env.CREATOR_VIEW = opts.view
485
+ // Frames the host prints name the minified bundle (`module.js:5:51631`); map them back to
486
+ // project positions. Chunks stay raw so `[script]` marker parsing sees exactly what was sent —
487
+ // the rewrite happens on the joined text (and best-effort per chunk for live --logs).
488
+ const demangle = makeStackDemangler(js) ?? ((text: string) => text)
489
+ return await new Promise<DesktopRenderOutcome>((done, fail) => {
490
+ const child = spawn(exe, [appJs], { cwd: runDir, env, stdio: ["ignore", "pipe", "pipe"] })
491
+ // Keep the whole output (scripted runs parse its `[script]` markers + console lines); forward
492
+ // live only with --logs. The error tail is the last 80 chunks.
493
+ const chunks: string[] = []
494
+ let stopped = false
495
+ const sink = (d: unknown) => {
496
+ const text = String(d)
497
+ chunks.push(text)
498
+ if (opts.logs) process.stderr.write(demangle(text))
499
+ if (!stopped && opts.stopOnLine?.(text)) { stopped = true; child.kill() }
500
+ }
501
+ child.stdout.on("data", sink)
502
+ child.stderr.on("data", sink)
503
+ const killer = setTimeout(() => {
504
+ child.kill()
505
+ fail(new CliError(`The native render timed out after ${opts.timeoutMs}ms (raise --timeout, or lower --frames).`))
506
+ }, opts.timeoutMs)
507
+ child.on("error", (e) => { clearTimeout(killer); fail(new CliError(`Can't run the desktop renderer: ${e.message}`)) })
508
+ child.on("exit", (code) => {
509
+ clearTimeout(killer)
510
+ const output = demangle(chunks.join(""))
511
+ if (stopped) return done({ output, exitCode: 0 })
512
+ if (code === 0 && existsSync(outAbs)) return done({ output, exitCode: code })
513
+ const tail = demangle(chunks.slice(-80).join("")).trimEnd()
514
+ const detail = tail.length > 0 ? `\n${tail}` : ""
515
+ fail(new CliError(code === 0
516
+ ? `The renderer exited cleanly but produced no PNG — did the app open a screen?${detail}`
517
+ : `The renderer exited with code ${code}.${detail}`))
518
+ })
519
+ })
520
+ } finally {
521
+ // A host that was just killed (timeout / stopOnLine) can still hold the staging dir for a moment;
522
+ // an EPERM here must not replace the real error with "Permission denied" (seen on a 10-minute
523
+ // lightmap timeout, read as a crash for two hours). Retry briefly, then leave it to the OS temp.
524
+ for (let attempt = 0; ; attempt++) {
525
+ try { rmSync(runDir, { recursive: true, force: true }); break }
526
+ catch {
527
+ if (attempt >= 5) break
528
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200)
529
+ }
530
+ }
531
+ }
532
+ }