lecodes-cli 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +1069 -396
  3. package/package.json +14 -13
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk-types.json +1 -1
  6. package/runtime/web/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
  7. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  8. package/runtime/web/assets/basis-C64VHDVD.js +1 -0
  9. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  10. package/runtime/web/assets/createViewerLite-BvHobVhp.js +852 -0
  11. package/runtime/web/assets/draco-BiISTFcR.js +118 -0
  12. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  13. package/runtime/web/assets/index-CfNCCBjN.js +2 -0
  14. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +810 -0
  15. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +8 -0
  16. package/runtime/web/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  17. package/runtime/web/assets/worker-Caf-yYEI.js +2 -0
  18. package/runtime/web/embed.html +32 -0
  19. package/runtime/web/embed.js +171 -0
  20. package/src/api.ts +302 -0
  21. package/src/browserAuth.ts +87 -0
  22. package/src/cmgenTool.ts +104 -0
  23. package/src/commands/app.ts +896 -0
  24. package/src/commands/appAndroid.ts +603 -0
  25. package/src/commands/appDesktop.ts +339 -0
  26. package/src/commands/appDesktopMac.ts +470 -0
  27. package/src/commands/appIcon.ts +187 -0
  28. package/src/commands/appShared.ts +542 -0
  29. package/src/commands/appTemplates.ts +907 -0
  30. package/src/commands/appTemplatesAndroid.ts +599 -0
  31. package/src/commands/appTemplatesGradlew.ts +9 -0
  32. package/src/commands/assets.ts +28 -0
  33. package/src/commands/clone.ts +60 -0
  34. package/src/commands/compile.ts +253 -0
  35. package/src/commands/create.ts +59 -0
  36. package/src/commands/design.ts +863 -0
  37. package/src/commands/designTemplates.ts +11 -0
  38. package/src/commands/desktop.ts +127 -0
  39. package/src/commands/dev.ts +236 -0
  40. package/src/commands/diff.ts +59 -0
  41. package/src/commands/init.ts +191 -0
  42. package/src/commands/install.ts +198 -0
  43. package/src/commands/lightmap.ts +290 -0
  44. package/src/commands/link.ts +147 -0
  45. package/src/commands/login.ts +24 -0
  46. package/src/commands/navmesh.ts +225 -0
  47. package/src/commands/pn.ts +305 -0
  48. package/src/commands/projectTemplates.ts +130 -0
  49. package/src/commands/pull.ts +109 -0
  50. package/src/commands/push.ts +173 -0
  51. package/src/commands/render.ts +416 -0
  52. package/src/commands/scene.ts +148 -0
  53. package/src/commands/shaders.ts +157 -0
  54. package/src/commands/status.ts +29 -0
  55. package/src/commands/test.ts +371 -0
  56. package/src/commands/thumbs.ts +178 -0
  57. package/src/commands/types.ts +87 -0
  58. package/src/commands/update.ts +190 -0
  59. package/src/compile/assetIcons.ts +214 -0
  60. package/src/compile/collect.ts +46 -0
  61. package/src/compile/collectLocal.ts +37 -0
  62. package/src/compile/designCompile.ts +104 -0
  63. package/src/compile/fonts.ts +156 -0
  64. package/src/compile/headlessBundle.ts +129 -0
  65. package/src/compile/nativeStack.ts +21 -0
  66. package/src/compile/projectCompile.ts +109 -0
  67. package/src/compile/sceneCompile.ts +113 -0
  68. package/src/compile/screenEntry.ts +127 -0
  69. package/src/compile/shaders.ts +245 -0
  70. package/src/config.ts +42 -0
  71. package/src/designMeta.ts +35 -0
  72. package/src/desktopRenderer.ts +545 -0
  73. package/src/desktopScript.ts +276 -0
  74. package/src/dev/clientTemplates.ts +160 -0
  75. package/src/dev/devServer.ts +333 -0
  76. package/src/dev/webRunner.ts +22 -0
  77. package/src/dev/wsServer.ts +144 -0
  78. package/src/distRoot.ts +20 -0
  79. package/src/ignore.ts +163 -0
  80. package/src/index.ts +494 -0
  81. package/src/lecodes-3d-editor.d.ts +41 -0
  82. package/src/lecodes-assets.d.ts +7 -0
  83. package/src/lecodes-design.d.ts +191 -0
  84. package/src/lecodes-renderer.d.ts +131 -0
  85. package/src/localFiles.ts +144 -0
  86. package/src/manifest.ts +46 -0
  87. package/src/matcTool.ts +137 -0
  88. package/src/peers.ts +40 -0
  89. package/src/project.ts +61 -0
  90. package/src/projectEnv.ts +94 -0
  91. package/src/qrcode-terminal.d.ts +9 -0
  92. package/src/releases.ts +125 -0
  93. package/src/serverDiff.ts +78 -0
  94. package/src/textDiff.ts +103 -0
  95. package/src/types.ts +0 -0
  96. package/src/util.ts +146 -0
  97. package/runtime/sdk/animate/animate.ts +0 -238
  98. package/runtime/sdk/animate/bezier.ts +0 -138
  99. package/runtime/sdk/animate/easings.ts +0 -126
  100. package/runtime/sdk/canvas/Canvas.ts +0 -305
  101. package/runtime/sdk/compile/aspectMacro.ts +0 -86
  102. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  103. package/runtime/sdk/compile/assetMacro.ts +0 -146
  104. package/runtime/sdk/compile/assetName.ts +0 -50
  105. package/runtime/sdk/compile/bundler.ts +0 -287
  106. package/runtime/sdk/compile/compileProject.ts +0 -129
  107. package/runtime/sdk/compile/detectEntry.ts +0 -128
  108. package/runtime/sdk/compile/fontMacro.ts +0 -459
  109. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  110. package/runtime/sdk/compile/header.ts +0 -67
  111. package/runtime/sdk/compile/index.ts +0 -100
  112. package/runtime/sdk/compile/libraryImports.ts +0 -52
  113. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  114. package/runtime/sdk/compile/sceneEditor.ts +0 -88
  115. package/runtime/sdk/compile/serverSplit.ts +0 -233
  116. package/runtime/sdk/compile/serverTypes.ts +0 -227
  117. package/runtime/sdk/compile/sfnt.ts +0 -98
  118. package/runtime/sdk/compile/shaderSchema.ts +0 -202
  119. package/runtime/sdk/compile/shaderTargets.ts +0 -81
  120. package/runtime/sdk/compile/sourcemap.ts +0 -25
  121. package/runtime/sdk/core/Aspect.ts +0 -568
  122. package/runtime/sdk/core/InspectorUI.ts +0 -212
  123. package/runtime/sdk/core/StateMachine.ts +0 -308
  124. package/runtime/sdk/core/color.ts +0 -66
  125. package/runtime/sdk/core/compWrite.ts +0 -42
  126. package/runtime/sdk/core/events.ts +0 -38
  127. package/runtime/sdk/core/fields.ts +0 -120
  128. package/runtime/sdk/core/registry.ts +0 -23
  129. package/runtime/sdk/core/signals.ts +0 -277
  130. package/runtime/sdk/core/time.ts +0 -84
  131. package/runtime/sdk/g2/Camera2D.ts +0 -40
  132. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  133. package/runtime/sdk/g2/Node2D.ts +0 -267
  134. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  135. package/runtime/sdk/g2/Physics2D.ts +0 -346
  136. package/runtime/sdk/g2/Scene2D.ts +0 -209
  137. package/runtime/sdk/g2/Shape2D.ts +0 -259
  138. package/runtime/sdk/g2/Sprite.ts +0 -89
  139. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  140. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  141. package/runtime/sdk/g2/Texture2D.ts +0 -47
  142. package/runtime/sdk/g2/Tilemap.ts +0 -41
  143. package/runtime/sdk/g2/Tileset.ts +0 -71
  144. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  145. package/runtime/sdk/g2/autotile.ts +0 -433
  146. package/runtime/sdk/g2/cells.ts +0 -91
  147. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  148. package/runtime/sdk/g2/groups2d.ts +0 -106
  149. package/runtime/sdk/g2/loop.ts +0 -50
  150. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  151. package/runtime/sdk/g2/touch.ts +0 -83
  152. package/runtime/sdk/gl/Camera.ts +0 -160
  153. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  154. package/runtime/sdk/gl/CharacterController.ts +0 -238
  155. package/runtime/sdk/gl/Gearbox.ts +0 -212
  156. package/runtime/sdk/gl/Geometry.ts +0 -279
  157. package/runtime/sdk/gl/IK.ts +0 -193
  158. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  159. package/runtime/sdk/gl/Light.ts +0 -107
  160. package/runtime/sdk/gl/Lightmap.ts +0 -256
  161. package/runtime/sdk/gl/Material.ts +0 -260
  162. package/runtime/sdk/gl/Mesh.ts +0 -83
  163. package/runtime/sdk/gl/Model.ts +0 -124
  164. package/runtime/sdk/gl/NavAgent.ts +0 -337
  165. package/runtime/sdk/gl/NavMesh.ts +0 -397
  166. package/runtime/sdk/gl/Node.ts +0 -350
  167. package/runtime/sdk/gl/Noise.ts +0 -30
  168. package/runtime/sdk/gl/Particles.ts +0 -676
  169. package/runtime/sdk/gl/Physics.ts +0 -222
  170. package/runtime/sdk/gl/Plane.ts +0 -53
  171. package/runtime/sdk/gl/Ragdoll.ts +0 -270
  172. package/runtime/sdk/gl/Ray.ts +0 -16
  173. package/runtime/sdk/gl/Scene.ts +0 -486
  174. package/runtime/sdk/gl/Shape.ts +0 -409
  175. package/runtime/sdk/gl/Terrain.ts +0 -1092
  176. package/runtime/sdk/gl/Texture.ts +0 -63
  177. package/runtime/sdk/gl/Trigger.ts +0 -45
  178. package/runtime/sdk/gl/Vehicle.ts +0 -473
  179. package/runtime/sdk/gl/Wheel.ts +0 -240
  180. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  181. package/runtime/sdk/gl/animation/Animator.ts +0 -120
  182. package/runtime/sdk/gl/animation/Layer.ts +0 -31
  183. package/runtime/sdk/gl/animation/Loop.ts +0 -30
  184. package/runtime/sdk/gl/animation/Playback.ts +0 -44
  185. package/runtime/sdk/gl/animation/core.ts +0 -336
  186. package/runtime/sdk/gl/controls.ts +0 -95
  187. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  188. package/runtime/sdk/gl/scenarios.ts +0 -291
  189. package/runtime/sdk/gl/state.ts +0 -6
  190. package/runtime/sdk/gl/terrainMesh.ts +0 -219
  191. package/runtime/sdk/gl/touch.ts +0 -68
  192. package/runtime/sdk/inject.ts +0 -197
  193. package/runtime/sdk/math/Mathf.ts +0 -118
  194. package/runtime/sdk/math/mat4.ts +0 -278
  195. package/runtime/sdk/math/quat.ts +0 -232
  196. package/runtime/sdk/math/vec.ts +0 -255
  197. package/runtime/sdk/net/codec.ts +0 -119
  198. package/runtime/sdk/net/core.ts +0 -384
  199. package/runtime/sdk/net/index.ts +0 -181
  200. package/runtime/sdk/net/replication.ts +0 -622
  201. package/runtime/sdk/plugins/camera.ts +0 -81
  202. package/runtime/sdk/plugins/geolocation.ts +0 -123
  203. package/runtime/sdk/plugins/oauth.ts +0 -61
  204. package/runtime/sdk/plugins/permission.ts +0 -7
  205. package/runtime/sdk/plugins/push.ts +0 -132
  206. package/runtime/sdk/plugins/qr.ts +0 -73
  207. package/runtime/sdk/plugins/service.ts +0 -47
  208. package/runtime/sdk/runtime/app.ts +0 -101
  209. package/runtime/sdk/runtime/appEvents.ts +0 -54
  210. package/runtime/sdk/runtime/channel.ts +0 -61
  211. package/runtime/sdk/runtime/clipboard.ts +0 -20
  212. package/runtime/sdk/runtime/datetime.ts +0 -329
  213. package/runtime/sdk/runtime/device.ts +0 -293
  214. package/runtime/sdk/runtime/fetch.ts +0 -77
  215. package/runtime/sdk/runtime/files.ts +0 -23
  216. package/runtime/sdk/runtime/input.ts +0 -175
  217. package/runtime/sdk/runtime/media.ts +0 -111
  218. package/runtime/sdk/runtime/misc.ts +0 -16
  219. package/runtime/sdk/runtime/net.ts +0 -36
  220. package/runtime/sdk/runtime/rpc.ts +0 -218
  221. package/runtime/sdk/runtime/service.ts +0 -83
  222. package/runtime/sdk/runtime/share.ts +0 -9
  223. package/runtime/sdk/runtime/storage.ts +0 -13
  224. package/runtime/sdk/runtime/touch.ts +0 -76
  225. package/runtime/sdk/scene/defineScene.ts +0 -1353
  226. package/runtime/sdk/scene/editorPlugins.ts +0 -110
  227. package/runtime/sdk/scene/gizmos.ts +0 -148
  228. package/runtime/sdk/scene/grammar.ts +0 -120
  229. package/runtime/sdk/scene/material.ts +0 -188
  230. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  231. package/runtime/sdk/server/auth/global.ts +0 -80
  232. package/runtime/sdk/server/auth/host.ts +0 -318
  233. package/runtime/sdk/server/auth/models.ts +0 -83
  234. package/runtime/sdk/server/auth/types.ts +0 -50
  235. package/runtime/sdk/server/channel.ts +0 -56
  236. package/runtime/sdk/server/context.ts +0 -36
  237. package/runtime/sdk/server/db/defineDb.ts +0 -237
  238. package/runtime/sdk/server/db/fields.ts +0 -132
  239. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  240. package/runtime/sdk/server/db/index.ts +0 -7
  241. package/runtime/sdk/server/db/marci/query.ts +0 -412
  242. package/runtime/sdk/server/db/types.ts +0 -202
  243. package/runtime/sdk/server/errors.ts +0 -12
  244. package/runtime/sdk/server/host.ts +0 -74
  245. package/runtime/sdk/server/inject.ts +0 -13
  246. package/runtime/sdk/server/runtime.ts +0 -133
  247. package/runtime/sdk/server/validate.ts +0 -87
  248. package/runtime/sdk/ui/NativeView.ts +0 -142
  249. package/runtime/sdk/ui/UI.ts +0 -39
  250. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  251. package/runtime/sdk/ui/UIButton.ts +0 -101
  252. package/runtime/sdk/ui/UIContainer.ts +0 -60
  253. package/runtime/sdk/ui/UIImage.ts +0 -83
  254. package/runtime/sdk/ui/UIInput.ts +0 -185
  255. package/runtime/sdk/ui/UIModal.ts +0 -139
  256. package/runtime/sdk/ui/UINode.ts +0 -830
  257. package/runtime/sdk/ui/UIPager.ts +0 -362
  258. package/runtime/sdk/ui/UIPopover.ts +0 -100
  259. package/runtime/sdk/ui/UIScreen.ts +0 -123
  260. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  261. package/runtime/sdk/ui/UISpacer.ts +0 -14
  262. package/runtime/sdk/ui/UITabs.ts +0 -236
  263. package/runtime/sdk/ui/UIText.ts +0 -51
  264. package/runtime/sdk/ui/UIVideo.ts +0 -88
  265. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  266. package/runtime/sdk/ui/UIWidget.ts +0 -127
  267. package/runtime/sdk/ui/fonts.ts +0 -13
  268. package/runtime/sdk/ui/presentable.ts +0 -117
  269. package/runtime/sdk/ui/router.ts +0 -132
  270. package/runtime/sdk/ui/theme.ts +0 -84
@@ -0,0 +1,545 @@
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 { delimiter, 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
+ /** Dirs the host scans for native plugin libraries (CREATOR_PLUGIN_DIRS; the loader was added
303
+ * after host 1.4.0 — an older host ignores the env). See appShared.desktopPluginLibraries. */
304
+ pluginDirs?: string[]
305
+ }
306
+
307
+ /** CREATOR_PLUGIN_DIRS: the project's plugin dirs first, then whatever the environment already
308
+ * carried (a `.env` CREATOR_PLUGIN_DIRS keeps working alongside the project's `plugins/`). */
309
+ const pluginEnv = (dirs: string[] | undefined): Record<string, string> => {
310
+ const all = [...(dirs ?? []), ...(process.env.CREATOR_PLUGIN_DIRS ? [process.env.CREATOR_PLUGIN_DIRS] : [])]
311
+ return all.length > 0 ? { CREATOR_PLUGIN_DIRS: all.join(delimiter) } : {}
312
+ }
313
+
314
+ const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
315
+ const env: Record<string, string> = { ...pluginEnv(opts.pluginDirs) }
316
+ // A lone width/height fills the other side from the host default so it isn't silently ignored.
317
+ const width = opts.width ?? (opts.height !== undefined ? 1280 : undefined)
318
+ const height = opts.height ?? (opts.width !== undefined ? 720 : undefined)
319
+ if (width && height) env.CREATOR_WINDOW_SIZE = `${width}x${height}`
320
+ if (opts.minWidth && opts.minHeight) env.CREATOR_MIN_WINDOW_SIZE = `${opts.minWidth}x${opts.minHeight}`
321
+ if (opts.title) env.CREATOR_WINDOW_TITLE = opts.title
322
+ if (opts.resizable === false) env.CREATOR_WINDOW_FIXED = "1"
323
+ if (opts.fullscreen !== undefined) env.CREATOR_WINDOW_FULLSCREEN = opts.fullscreen ? "1" : "0"
324
+ if (opts.scale === "fit") env.CREATOR_UI_SCALE = "fit"
325
+ else if (typeof opts.scale === "number") env.CREATOR_UI_SCALE = String(opts.scale)
326
+ if (typeof opts.renderScale === "number") env.CREATOR_RENDER_SCALE = String(opts.renderScale)
327
+ if (opts.maxRenderSize && opts.maxRenderSize[0] > 0 && opts.maxRenderSize[1] > 0) {
328
+ env.CREATOR_RENDER_MAX = `${Math.round(opts.maxRenderSize[0])}x${Math.round(opts.maxRenderSize[1])}`
329
+ }
330
+ if (opts.fps) env.CREATOR_FPS = "1"
331
+ return env
332
+ }
333
+
334
+ // The host's own multiplayer flags (main.cpp argv) — options, not env, so a shipped exe takes them too.
335
+ const netArgs = (net: DesktopWindowOpts["net"]): string[] => {
336
+ if (!net) return []
337
+ const args: string[] = []
338
+ if (net.role === "server") args.push("--server")
339
+ else if (net.address) args.push("--connect", net.address)
340
+ if (net.port) args.push("--port", String(net.port))
341
+ if (net.maxClients) args.push("--max-clients", String(net.maxClients))
342
+ return args
343
+ }
344
+
345
+ export type DesktopRenderOptions = {
346
+ /** Called per output chunk; returning true ends the run early (the host is killed, the
347
+ * outcome is a success with the output so far — a bake that printed its done marker). */
348
+ stopOnLine?: (chunk: string) => boolean
349
+ /** Which host binary to run (see DesktopRenderer). Must match the renderer the bundle's shaders
350
+ * were staged for — resolve it once per command and pass the same value to both. */
351
+ renderer?: DesktopRenderer
352
+ width: number
353
+ height: number
354
+ /** Frames to run before the dump — headroom for async loads (GLB, images, fonts). */
355
+ frames: number
356
+ /** Absolute or cwd-relative path the PNG lands at. */
357
+ outPng: string
358
+ /** Forward the host's stdout/stderr (app console.log included) to stderr. */
359
+ logs: boolean
360
+ timeoutMs: number
361
+ /** CREATOR_SCRIPT contents (the line format in packages/desktop/src/main.cpp `scriptStep`): the
362
+ * host drives these commands and self-terminates when done; `frames` is then only a safety cap. */
363
+ script?: string
364
+ /** CREATOR_FIXED_DT: virtual app clock, ms per frame (0/undefined = wall clock). */
365
+ fixedDtMs?: number
366
+ /** Inspection camera: CREATOR_CAMERA ("ex,ey,ez;tx,ty,tz[;fov]") or CREATOR_VIEW ("iso[;fov]"). */
367
+ camera?: string
368
+ view?: string
369
+ /** Dirs the host scans for native plugin libraries (CREATOR_PLUGIN_DIRS). */
370
+ pluginDirs?: string[]
371
+ }
372
+
373
+ export type DesktopRenderOutcome = {
374
+ /** Everything the host printed (stdout + stderr), for marker parsing / expect.log. */
375
+ output: string
376
+ exitCode: number | null
377
+ }
378
+
379
+ /** Stage a compiled bundle for the host: app.js + the local-asset resources copied next to it
380
+ * (the host's fetchLocal resolves them by basename relative to its cwd). Caller removes the dir. */
381
+ const stageBundle = (js: string, resources: { name: string, absPath?: string }[]): string => {
382
+ const runDir = mkdtempSync(join(tmpdir(), "lecodes-render-"))
383
+ writeFileSync(join(runDir, "app.js"), js)
384
+ for (const r of resources) {
385
+ if (r.absPath && existsSync(r.absPath) && statSync(r.absPath).isFile()) {
386
+ copyFileSync(r.absPath, join(runDir, r.name))
387
+ }
388
+ }
389
+ return runDir
390
+ }
391
+
392
+ /** Forward a child stream line-by-line through `map`. Line-buffered so a chunk boundary can never
393
+ * cut a `module.js:L:C` frame in half; the trailing partial line is flushed when the stream ends. */
394
+ const pipeThroughDemangler = (src: NodeJS.ReadableStream, dst: NodeJS.WritableStream, map: (s: string) => string) => {
395
+ let rest = ""
396
+ src.on("data", (d: Buffer | string) => {
397
+ rest += String(d)
398
+ const nl = rest.lastIndexOf("\n")
399
+ if (nl < 0) return
400
+ dst.write(map(rest.slice(0, nl + 1)))
401
+ rest = rest.slice(nl + 1)
402
+ })
403
+ src.on("end", () => { if (rest) dst.write(map(rest)) })
404
+ }
405
+
406
+ /** `lecodes desktop run`: open the compiled project in a normal (visible, interactive) desktop
407
+ * window and stream the host's output — the plain-run counterpart of `lecodes dev`, no reload.
408
+ * Resolves until the window is closed. Ambient CREATOR_* envs pass through (handy for testing). */
409
+ export const runDesktopApp = async (
410
+ js: string,
411
+ resources: { name: string, absPath?: string }[],
412
+ opts: DesktopWindowOpts = {},
413
+ ): Promise<void> => {
414
+ const exe = await resolveDesktopExe(opts.renderer)
415
+ const runDir = stageBundle(js, resources)
416
+ try {
417
+ const env = { ...process.env, ...windowEnv(opts) }
418
+ const demangle = makeStackDemangler(js)
419
+ const argv = [join(runDir, "app.js"), ...netArgs(opts.net)]
420
+ await new Promise<void>((done, fail) => {
421
+ // Piped (not inherited) only when there's a map to apply — the host's frames name the minified
422
+ // bundle (`module.js:5:51631`) and we rewrite them to project positions on the way through.
423
+ const child = demangle
424
+ ? spawn(exe, argv, { cwd: runDir, env, stdio: ["ignore", "pipe", "pipe"] })
425
+ : spawn(exe, argv, { cwd: runDir, env, stdio: ["ignore", "inherit", "inherit"] })
426
+ if (demangle) {
427
+ pipeThroughDemangler(child.stdout!, process.stdout, demangle)
428
+ pipeThroughDemangler(child.stderr!, process.stderr, demangle)
429
+ }
430
+ child.on("error", (e) => fail(new CliError(`Can't run the desktop host: ${e.message}`)))
431
+ child.on("exit", (code) => {
432
+ if (code === 0 || code === null) done() // null: killed (e.g. Ctrl+C reached the child)
433
+ else fail(new CliError(`The desktop host exited with code ${code}.`))
434
+ })
435
+ })
436
+ } finally {
437
+ // A host that was just killed (timeout / stopOnLine) can still hold the staging dir for a moment;
438
+ // an EPERM here must not replace the real error with "Permission denied" (seen on a 10-minute
439
+ // lightmap timeout, read as a crash for two hours). Retry briefly, then leave it to the OS temp.
440
+ for (let attempt = 0; ; attempt++) {
441
+ try { rmSync(runDir, { recursive: true, force: true }); break }
442
+ catch {
443
+ if (attempt >= 5) break
444
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200)
445
+ }
446
+ }
447
+ }
448
+ }
449
+
450
+ /** `lecodes dev --desktop`: open the dev-server URL in the host — its launcher fetches the
451
+ * bundle and the appended prelude hot-reloads over websocket. No staging dir: assets stream
452
+ * from the dev server. Resolves when the window closes (any exit code — a dev host getting
453
+ * killed mid-session is not an error); the caller's dev server keeps running. stdout is
454
+ * dropped: the prelude already mirrors app console output to the dev terminal. */
455
+ export const runDesktopDev = async (url: string, opts: DesktopWindowOpts = {}): Promise<void> => {
456
+ const exe = await resolveDesktopExe(opts.renderer)
457
+ const env = { ...process.env, ...windowEnv(opts) }
458
+ await new Promise<void>((done, fail) => {
459
+ const child = spawn(exe, [url], { env, stdio: ["ignore", "ignore", "inherit"] })
460
+ child.on("error", (e) => fail(new CliError(`Can't run the desktop host: ${e.message}`)))
461
+ child.on("exit", () => done())
462
+ })
463
+ }
464
+
465
+ /** Run a compiled bundle in the hidden desktop host and dump the final frame as a PNG. */
466
+ export const runDesktopRender = async (
467
+ js: string,
468
+ resources: { name: string, absPath?: string }[],
469
+ opts: DesktopRenderOptions,
470
+ ): Promise<DesktopRenderOutcome> => {
471
+ if (isMac()) {
472
+ throw new CliError(
473
+ "`lecodes render --desktop` isn't available on macOS yet — the macOS host has no headless mode " +
474
+ "(CREATOR_HIDDEN / CREATOR_DUMP_PNG). `lecodes desktop run` and `lecodes desktop build` do work there.",
475
+ )
476
+ }
477
+ const exe = await resolveDesktopExe(opts.renderer)
478
+ const runDir = stageBundle(js, resources)
479
+ try {
480
+ const appJs = join(runDir, "app.js")
481
+ const outAbs = resolve(opts.outPng)
482
+ const env: Record<string, string | undefined> = {
483
+ ...process.env,
484
+ CREATOR_DUMP_PNG: outAbs,
485
+ CREATOR_MAX_FRAMES: String(opts.frames),
486
+ CREATOR_WINDOW_SIZE: `${opts.width}x${opts.height}`,
487
+ CREATOR_HIDDEN: "1",
488
+ }
489
+ if (opts.script !== undefined) {
490
+ const scriptPath = join(runDir, "script.txt")
491
+ writeFileSync(scriptPath, opts.script)
492
+ env.CREATOR_SCRIPT = scriptPath
493
+ }
494
+ if (opts.fixedDtMs) env.CREATOR_FIXED_DT = String(opts.fixedDtMs)
495
+ if (opts.camera) env.CREATOR_CAMERA = opts.camera
496
+ if (opts.view) env.CREATOR_VIEW = opts.view
497
+ Object.assign(env, pluginEnv(opts.pluginDirs))
498
+ // Frames the host prints name the minified bundle (`module.js:5:51631`); map them back to
499
+ // project positions. Chunks stay raw so `[script]` marker parsing sees exactly what was sent —
500
+ // the rewrite happens on the joined text (and best-effort per chunk for live --logs).
501
+ const demangle = makeStackDemangler(js) ?? ((text: string) => text)
502
+ return await new Promise<DesktopRenderOutcome>((done, fail) => {
503
+ const child = spawn(exe, [appJs], { cwd: runDir, env, stdio: ["ignore", "pipe", "pipe"] })
504
+ // Keep the whole output (scripted runs parse its `[script]` markers + console lines); forward
505
+ // live only with --logs. The error tail is the last 80 chunks.
506
+ const chunks: string[] = []
507
+ let stopped = false
508
+ const sink = (d: unknown) => {
509
+ const text = String(d)
510
+ chunks.push(text)
511
+ if (opts.logs) process.stderr.write(demangle(text))
512
+ if (!stopped && opts.stopOnLine?.(text)) { stopped = true; child.kill() }
513
+ }
514
+ child.stdout.on("data", sink)
515
+ child.stderr.on("data", sink)
516
+ const killer = setTimeout(() => {
517
+ child.kill()
518
+ fail(new CliError(`The native render timed out after ${opts.timeoutMs}ms (raise --timeout, or lower --frames).`))
519
+ }, opts.timeoutMs)
520
+ child.on("error", (e) => { clearTimeout(killer); fail(new CliError(`Can't run the desktop renderer: ${e.message}`)) })
521
+ child.on("exit", (code) => {
522
+ clearTimeout(killer)
523
+ const output = demangle(chunks.join(""))
524
+ if (stopped) return done({ output, exitCode: 0 })
525
+ if (code === 0 && existsSync(outAbs)) return done({ output, exitCode: code })
526
+ const tail = demangle(chunks.slice(-80).join("")).trimEnd()
527
+ const detail = tail.length > 0 ? `\n${tail}` : ""
528
+ fail(new CliError(code === 0
529
+ ? `The renderer exited cleanly but produced no PNG — did the app open a screen?${detail}`
530
+ : `The renderer exited with code ${code}.${detail}`))
531
+ })
532
+ })
533
+ } finally {
534
+ // A host that was just killed (timeout / stopOnLine) can still hold the staging dir for a moment;
535
+ // an EPERM here must not replace the real error with "Permission denied" (seen on a 10-minute
536
+ // lightmap timeout, read as a crash for two hours). Retry briefly, then leave it to the OS temp.
537
+ for (let attempt = 0; ; attempt++) {
538
+ try { rmSync(runDir, { recursive: true, force: true }); break }
539
+ catch {
540
+ if (attempt >= 5) break
541
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200)
542
+ }
543
+ }
544
+ }
545
+ }