lecodes-cli 0.18.2 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/dist/index.js +1866 -301
  2. package/package.json +14 -12
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk-types.json +1 -1
  5. package/src/api.ts +302 -0
  6. package/src/browserAuth.ts +87 -0
  7. package/src/cmgenTool.ts +104 -0
  8. package/src/commands/app.ts +891 -0
  9. package/src/commands/appAndroid.ts +603 -0
  10. package/src/commands/appDesktop.ts +326 -0
  11. package/src/commands/appDesktopMac.ts +470 -0
  12. package/src/commands/appIcon.ts +187 -0
  13. package/src/commands/appShared.ts +448 -0
  14. package/src/commands/appTemplates.ts +883 -0
  15. package/src/commands/appTemplatesAndroid.ts +599 -0
  16. package/src/commands/appTemplatesGradlew.ts +9 -0
  17. package/src/commands/assets.ts +28 -0
  18. package/src/commands/clone.ts +60 -0
  19. package/src/commands/compile.ts +253 -0
  20. package/src/commands/create.ts +59 -0
  21. package/src/commands/design.ts +863 -0
  22. package/src/commands/designTemplates.ts +11 -0
  23. package/src/commands/desktop.ts +122 -0
  24. package/src/commands/dev.ts +214 -0
  25. package/src/commands/diff.ts +59 -0
  26. package/src/commands/init.ts +191 -0
  27. package/src/commands/install.ts +198 -0
  28. package/src/commands/lightmap.ts +290 -0
  29. package/src/commands/link.ts +147 -0
  30. package/src/commands/login.ts +24 -0
  31. package/src/commands/navmesh.ts +225 -0
  32. package/src/commands/pn.ts +305 -0
  33. package/src/commands/projectTemplates.ts +129 -0
  34. package/src/commands/pull.ts +109 -0
  35. package/src/commands/push.ts +173 -0
  36. package/src/commands/render.ts +414 -0
  37. package/src/commands/scene.ts +148 -0
  38. package/src/commands/shaders.ts +157 -0
  39. package/src/commands/status.ts +29 -0
  40. package/src/commands/test.ts +370 -0
  41. package/src/commands/thumbs.ts +178 -0
  42. package/src/commands/types.ts +87 -0
  43. package/src/commands/update.ts +190 -0
  44. package/src/compile/assetIcons.ts +214 -0
  45. package/src/compile/collect.ts +46 -0
  46. package/src/compile/collectLocal.ts +37 -0
  47. package/src/compile/designCompile.ts +104 -0
  48. package/src/compile/fonts.ts +156 -0
  49. package/src/compile/headlessBundle.ts +129 -0
  50. package/src/compile/nativeStack.ts +21 -0
  51. package/src/compile/projectCompile.ts +109 -0
  52. package/src/compile/sceneCompile.ts +113 -0
  53. package/src/compile/screenEntry.ts +127 -0
  54. package/src/compile/shaders.ts +245 -0
  55. package/src/config.ts +42 -0
  56. package/src/designMeta.ts +35 -0
  57. package/src/desktopRenderer.ts +532 -0
  58. package/src/desktopScript.ts +276 -0
  59. package/src/dev/clientTemplates.ts +160 -0
  60. package/src/dev/devServer.ts +292 -0
  61. package/src/dev/wsServer.ts +144 -0
  62. package/src/distRoot.ts +20 -0
  63. package/src/ignore.ts +163 -0
  64. package/src/index.ts +491 -0
  65. package/src/lecodes-3d-editor.d.ts +41 -0
  66. package/src/lecodes-assets.d.ts +7 -0
  67. package/src/lecodes-design.d.ts +191 -0
  68. package/src/lecodes-renderer.d.ts +131 -0
  69. package/src/localFiles.ts +144 -0
  70. package/src/manifest.ts +46 -0
  71. package/src/matcTool.ts +137 -0
  72. package/src/peers.ts +40 -0
  73. package/src/project.ts +61 -0
  74. package/src/projectEnv.ts +94 -0
  75. package/src/qrcode-terminal.d.ts +9 -0
  76. package/src/releases.ts +125 -0
  77. package/src/serverDiff.ts +78 -0
  78. package/src/textDiff.ts +103 -0
  79. package/src/types.ts +0 -0
  80. package/src/util.ts +146 -0
  81. package/runtime/sdk/animate/animate.ts +0 -238
  82. package/runtime/sdk/animate/bezier.ts +0 -138
  83. package/runtime/sdk/animate/easings.ts +0 -126
  84. package/runtime/sdk/canvas/Canvas.ts +0 -305
  85. package/runtime/sdk/core/Aspect.ts +0 -512
  86. package/runtime/sdk/core/InspectorUI.ts +0 -212
  87. package/runtime/sdk/core/color.ts +0 -66
  88. package/runtime/sdk/core/compWrite.ts +0 -42
  89. package/runtime/sdk/core/events.ts +0 -38
  90. package/runtime/sdk/core/fields.ts +0 -120
  91. package/runtime/sdk/core/registry.ts +0 -23
  92. package/runtime/sdk/core/signals.ts +0 -277
  93. package/runtime/sdk/core/time.ts +0 -81
  94. package/runtime/sdk/g2/Camera2D.ts +0 -40
  95. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  96. package/runtime/sdk/g2/Node2D.ts +0 -267
  97. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  98. package/runtime/sdk/g2/Physics2D.ts +0 -346
  99. package/runtime/sdk/g2/Scene2D.ts +0 -209
  100. package/runtime/sdk/g2/Shape2D.ts +0 -259
  101. package/runtime/sdk/g2/Sprite.ts +0 -89
  102. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  103. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  104. package/runtime/sdk/g2/Texture2D.ts +0 -47
  105. package/runtime/sdk/g2/Tilemap.ts +0 -41
  106. package/runtime/sdk/g2/Tileset.ts +0 -71
  107. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  108. package/runtime/sdk/g2/autotile.ts +0 -433
  109. package/runtime/sdk/g2/cells.ts +0 -91
  110. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  111. package/runtime/sdk/g2/groups2d.ts +0 -106
  112. package/runtime/sdk/g2/loop.ts +0 -50
  113. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  114. package/runtime/sdk/g2/touch.ts +0 -83
  115. package/runtime/sdk/gl/Camera.ts +0 -160
  116. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  117. package/runtime/sdk/gl/CharacterController.ts +0 -238
  118. package/runtime/sdk/gl/Gearbox.ts +0 -212
  119. package/runtime/sdk/gl/Geometry.ts +0 -279
  120. package/runtime/sdk/gl/IK.ts +0 -193
  121. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  122. package/runtime/sdk/gl/Light.ts +0 -99
  123. package/runtime/sdk/gl/Lightmap.ts +0 -179
  124. package/runtime/sdk/gl/Material.ts +0 -245
  125. package/runtime/sdk/gl/Mesh.ts +0 -83
  126. package/runtime/sdk/gl/Model.ts +0 -64
  127. package/runtime/sdk/gl/Node.ts +0 -350
  128. package/runtime/sdk/gl/Noise.ts +0 -30
  129. package/runtime/sdk/gl/Particles.ts +0 -676
  130. package/runtime/sdk/gl/Physics.ts +0 -222
  131. package/runtime/sdk/gl/Plane.ts +0 -53
  132. package/runtime/sdk/gl/Ray.ts +0 -16
  133. package/runtime/sdk/gl/Scene.ts +0 -479
  134. package/runtime/sdk/gl/Shape.ts +0 -377
  135. package/runtime/sdk/gl/Texture.ts +0 -46
  136. package/runtime/sdk/gl/Trigger.ts +0 -45
  137. package/runtime/sdk/gl/Vehicle.ts +0 -473
  138. package/runtime/sdk/gl/Wheel.ts +0 -240
  139. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  140. package/runtime/sdk/gl/animation/Animator.ts +0 -87
  141. package/runtime/sdk/gl/animation/Layer.ts +0 -29
  142. package/runtime/sdk/gl/animation/Loop.ts +0 -25
  143. package/runtime/sdk/gl/animation/Playback.ts +0 -43
  144. package/runtime/sdk/gl/animation/core.ts +0 -294
  145. package/runtime/sdk/gl/controls.ts +0 -95
  146. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  147. package/runtime/sdk/gl/scenarios.ts +0 -291
  148. package/runtime/sdk/gl/state.ts +0 -6
  149. package/runtime/sdk/gl/touch.ts +0 -68
  150. package/runtime/sdk/inject.ts +0 -186
  151. package/runtime/sdk/math/Mathf.ts +0 -118
  152. package/runtime/sdk/math/mat4.ts +0 -278
  153. package/runtime/sdk/math/quat.ts +0 -232
  154. package/runtime/sdk/math/vec.ts +0 -255
  155. package/runtime/sdk/plugins/camera.ts +0 -81
  156. package/runtime/sdk/plugins/geolocation.ts +0 -123
  157. package/runtime/sdk/plugins/oauth.ts +0 -61
  158. package/runtime/sdk/plugins/permission.ts +0 -7
  159. package/runtime/sdk/plugins/push.ts +0 -132
  160. package/runtime/sdk/plugins/qr.ts +0 -73
  161. package/runtime/sdk/plugins/service.ts +0 -47
  162. package/runtime/sdk/runtime/app.ts +0 -101
  163. package/runtime/sdk/runtime/appEvents.ts +0 -54
  164. package/runtime/sdk/runtime/channel.ts +0 -61
  165. package/runtime/sdk/runtime/clipboard.ts +0 -20
  166. package/runtime/sdk/runtime/datetime.ts +0 -329
  167. package/runtime/sdk/runtime/device.ts +0 -293
  168. package/runtime/sdk/runtime/fetch.ts +0 -77
  169. package/runtime/sdk/runtime/files.ts +0 -23
  170. package/runtime/sdk/runtime/input.ts +0 -175
  171. package/runtime/sdk/runtime/media.ts +0 -111
  172. package/runtime/sdk/runtime/misc.ts +0 -16
  173. package/runtime/sdk/runtime/net.ts +0 -36
  174. package/runtime/sdk/runtime/rpc.ts +0 -218
  175. package/runtime/sdk/runtime/service.ts +0 -83
  176. package/runtime/sdk/runtime/share.ts +0 -9
  177. package/runtime/sdk/runtime/storage.ts +0 -13
  178. package/runtime/sdk/runtime/touch.ts +0 -76
  179. package/runtime/sdk/scene/defineScene.ts +0 -1227
  180. package/runtime/sdk/scene/editorPlugins.ts +0 -92
  181. package/runtime/sdk/scene/gizmos.ts +0 -148
  182. package/runtime/sdk/scene/grammar.ts +0 -120
  183. package/runtime/sdk/scene/material.ts +0 -188
  184. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  185. package/runtime/sdk/server/auth/global.ts +0 -80
  186. package/runtime/sdk/server/auth/host.ts +0 -318
  187. package/runtime/sdk/server/auth/models.ts +0 -83
  188. package/runtime/sdk/server/auth/types.ts +0 -50
  189. package/runtime/sdk/server/channel.ts +0 -56
  190. package/runtime/sdk/server/context.ts +0 -36
  191. package/runtime/sdk/server/db/defineDb.ts +0 -237
  192. package/runtime/sdk/server/db/fields.ts +0 -132
  193. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  194. package/runtime/sdk/server/db/index.ts +0 -7
  195. package/runtime/sdk/server/db/marci/query.ts +0 -412
  196. package/runtime/sdk/server/db/types.ts +0 -202
  197. package/runtime/sdk/server/errors.ts +0 -12
  198. package/runtime/sdk/server/host.ts +0 -74
  199. package/runtime/sdk/server/inject.ts +0 -13
  200. package/runtime/sdk/server/runtime.ts +0 -133
  201. package/runtime/sdk/server/validate.ts +0 -87
  202. package/runtime/sdk/ui/NativeView.ts +0 -142
  203. package/runtime/sdk/ui/UI.ts +0 -39
  204. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  205. package/runtime/sdk/ui/UIButton.ts +0 -101
  206. package/runtime/sdk/ui/UIContainer.ts +0 -60
  207. package/runtime/sdk/ui/UIImage.ts +0 -83
  208. package/runtime/sdk/ui/UIInput.ts +0 -185
  209. package/runtime/sdk/ui/UIModal.ts +0 -139
  210. package/runtime/sdk/ui/UINode.ts +0 -826
  211. package/runtime/sdk/ui/UIPager.ts +0 -362
  212. package/runtime/sdk/ui/UIPopover.ts +0 -100
  213. package/runtime/sdk/ui/UIScreen.ts +0 -123
  214. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  215. package/runtime/sdk/ui/UISpacer.ts +0 -14
  216. package/runtime/sdk/ui/UITabs.ts +0 -236
  217. package/runtime/sdk/ui/UIText.ts +0 -51
  218. package/runtime/sdk/ui/UIVideo.ts +0 -88
  219. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  220. package/runtime/sdk/ui/UIWidget.ts +0 -127
  221. package/runtime/sdk/ui/fonts.ts +0 -13
  222. package/runtime/sdk/ui/presentable.ts +0 -117
  223. package/runtime/sdk/ui/router.ts +0 -132
  224. package/runtime/sdk/ui/theme.ts +0 -84
@@ -0,0 +1,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
+ }