lecodes-cli 0.20.2 → 2.0.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 (154) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +11052 -7592
  3. package/package.json +14 -8
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap.mat +117 -138
  7. package/runtime/materials/particles-quad.mat +6 -2
  8. package/runtime/materials/terrain-lightmap.mat +123 -0
  9. package/runtime/materials/terrain.mat +36 -55
  10. package/runtime/sdk-types.json +1 -1
  11. package/runtime/web/VERSION.json +5 -0
  12. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  13. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  14. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  15. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  16. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  17. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  18. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  19. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  20. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  21. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  22. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  23. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  24. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  25. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  26. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  27. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  28. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  29. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  30. package/runtime/web/index.html +36 -0
  31. package/runtime/web/player.js +21 -0
  32. package/runtime/web-page/index.html +41 -0
  33. package/runtime/web-page/page.css +1 -0
  34. package/runtime/web-page/page.js +31 -0
  35. package/src/api.ts +1 -302
  36. package/src/cli/args.ts +37 -0
  37. package/src/cli/command.ts +113 -0
  38. package/src/cli/errors.ts +13 -0
  39. package/src/cli/help.ts +158 -0
  40. package/src/cli/index.ts +7 -0
  41. package/src/cli/output.ts +102 -0
  42. package/src/cli/run.ts +106 -0
  43. package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
  44. package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
  45. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
  46. package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
  47. package/src/commands/{app.ts → app/index.ts} +400 -290
  48. package/src/commands/{appShared.ts → app/shared.ts} +43 -44
  49. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
  50. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  51. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
  52. package/src/commands/assets.ts +23 -21
  53. package/src/commands/clone.ts +48 -51
  54. package/src/commands/compile.ts +76 -89
  55. package/src/commands/create.ts +46 -47
  56. package/src/commands/design/comments.ts +361 -0
  57. package/src/commands/design/context.ts +101 -0
  58. package/src/commands/design/index.ts +391 -0
  59. package/src/commands/design/snapshot.ts +134 -0
  60. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  61. package/src/commands/desktop.ts +94 -82
  62. package/src/commands/dev.ts +260 -182
  63. package/src/commands/diff.ts +39 -51
  64. package/src/commands/index.ts +64 -0
  65. package/src/commands/{init.ts → init/index.ts} +195 -191
  66. package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
  67. package/src/commands/install.ts +80 -67
  68. package/src/commands/lightmap.ts +543 -252
  69. package/src/commands/link.ts +87 -94
  70. package/src/commands/login.ts +25 -23
  71. package/src/commands/navmesh.ts +169 -139
  72. package/src/commands/plugin.ts +46 -0
  73. package/src/commands/pn.ts +201 -244
  74. package/src/commands/pull.ts +49 -56
  75. package/src/commands/push.ts +48 -54
  76. package/src/commands/render.ts +176 -111
  77. package/src/commands/scene.ts +63 -55
  78. package/src/commands/serve.ts +58 -0
  79. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  80. package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
  81. package/src/commands/shared.ts +79 -0
  82. package/src/commands/status.ts +22 -26
  83. package/src/commands/test.ts +94 -63
  84. package/src/commands/thumbs.ts +185 -175
  85. package/src/commands/update/index.ts +226 -0
  86. package/src/commands/{types.ts → update/types.ts} +31 -22
  87. package/src/compile/collect.ts +5 -5
  88. package/src/compile/collectLocal.ts +3 -3
  89. package/src/compile/designCompile.ts +4 -4
  90. package/src/compile/headlessBundle.ts +16 -12
  91. package/src/compile/nativeStack.ts +1 -1
  92. package/src/compile/projectCompile.ts +12 -4
  93. package/src/compile/sceneCompile.ts +14 -26
  94. package/src/compile/screenEntry.ts +123 -127
  95. package/src/compile/shaders.ts +3 -3
  96. package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
  97. package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
  98. package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
  99. package/src/dev/androidDev.ts +1 -1
  100. package/src/dev/clientTemplates.ts +18 -14
  101. package/src/dev/devServer.ts +78 -41
  102. package/src/dev/iosDev.ts +180 -0
  103. package/src/dev/webRunner.ts +83 -15
  104. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
  105. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
  106. package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
  107. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  108. package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
  109. package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
  110. package/src/{peers.ts → hosts/peers.ts} +2 -2
  111. package/src/{releases.ts → hosts/releases.ts} +1 -1
  112. package/src/index.ts +35 -480
  113. package/src/platform/api.ts +302 -0
  114. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  115. package/src/{config.ts → platform/config.ts} +1 -1
  116. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  117. package/src/plugin/contract.ts +438 -0
  118. package/src/plugin/emitKotlin.ts +357 -0
  119. package/src/plugin/emitSdk.ts +259 -0
  120. package/src/plugin/emitSwift.ts +336 -0
  121. package/src/plugin/emitWeb.ts +383 -0
  122. package/src/plugin/generate.ts +157 -0
  123. package/src/plugin/lines.ts +28 -0
  124. package/src/plugin/lower.ts +231 -0
  125. package/src/plugin/manifest.ts +192 -0
  126. package/src/{projectEnv.ts → project/env.ts} +33 -2
  127. package/src/{ignore.ts → project/ignore.ts} +164 -163
  128. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  129. package/src/{manifest.ts → project/manifest.ts} +1 -1
  130. package/src/{project.ts → project/materialize.ts} +2 -2
  131. package/src/project/paths.ts +20 -0
  132. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  133. package/src/{types.ts → project/types.ts} +0 -0
  134. package/runtime/materials/lightmap-baked-lite.mat +0 -175
  135. package/runtime/materials/lightmap-baked.mat +0 -176
  136. package/runtime/scene-harness.json +0 -1
  137. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  138. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  139. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  140. package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
  141. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  142. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  143. package/runtime/web/assets/index-BMt7AnC5.js +0 -2
  144. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  145. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  146. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  147. package/runtime/web/embed.html +0 -32
  148. package/runtime/web/embed.js +0 -171
  149. package/src/commands/design.ts +0 -846
  150. package/src/commands/update.ts +0 -211
  151. package/src/util.ts +0 -146
  152. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  153. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  154. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
@@ -1,22 +1,90 @@
1
- import { existsSync } from "node:fs"
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, readdirSync, statSync } from "node:fs"
2
3
  import { createRequire } from "node:module"
3
4
  import { dirname, join } from "node:path"
4
- import { distRoot } from "../distRoot"
5
+ import { distRoot } from "../hosts/distRoot"
5
6
 
6
7
  /**
7
- * Where the browser runner for `lecodes dev --web` lives: viewer-lite's built `dist-embed`
8
- * (embed.html + embed.js + assets/ — the Web Lite host as a static folder). Two shapes:
9
- * - vendored next to the CLI as runtime/web (scripts/vendor-runtime.ts — the npm package and
10
- * the standalone binary),
11
- * - the lecodes-viewer-lite package's own dist-embed (monorepo dev after `bun run build:embed`).
12
- * `null` = no runner available (the dev server then serves everything but /web/).
8
+ * What `lecodes dev --web` serves to a browser, two built folders:
9
+ * player the web player (lecodes-web-player's dist: index.html + player.js + assets/ — the
10
+ * runtime module, the archives, the plugins' web halves): the app, filling its window
11
+ * page the page around it (lecodes-web-frame's dist-page: the app in a device, the buttons,
12
+ * the console), which holds the player in an <iframe>; absent = the player alone
13
+ *
14
+ * Each comes in one of two shapes:
15
+ * - its package's own build, in a CHECKOUT of the core (the package has its sources beside it):
16
+ * built here when it is older than what it is built from — a stale build is yesterday's host
17
+ * under today's SDK, and it fails in ways that name nothing;
18
+ * - vendored next to the CLI as runtime/web and runtime/web-page (scripts/vendor-runtime.ts —
19
+ * the npm package and the standalone binary).
20
+ * `null` = no player available (the dev server then serves everything but /web/).
13
21
  */
14
- export const resolveWebRunnerDir = (): string | null => {
15
- const candidates = [join(distRoot, "runtime", "web")]
22
+ export type WebRunner = { player: string, page: string | null }
23
+
24
+ type Built = {
25
+ /** The package, as npm names it. */
26
+ pkg: string
27
+ /** Its build, a folder of the package. */
28
+ dist: string
29
+ /** The file a build has. */
30
+ entry: string
31
+ /** The folder it is vendored as, beside the CLI. */
32
+ vendored: string
33
+ /** What it is built from, relative to the repo. */
34
+ sources: string[]
35
+ /** The arguments of the `vite build` that makes it. */
36
+ build: string[]
37
+ }
38
+
39
+ const PLAYER: Built = {
40
+ pkg: "lecodes-web-player", dist: "dist", entry: "player.js", vendored: "web", build: [],
41
+ sources: ["hosts/web-player/src", "hosts/web-player/build", "hosts/web-player/index.html", "hosts/web-host/src", "renderers/web-canvas/src", "renderers/web-shared/src", "runtime/web/src", "runtime/web/native/dist", "hosts/plugins/plugins"],
42
+ }
43
+ const PAGE: Built = {
44
+ // (the package's own build is the element, a library; the page is another)
45
+ pkg: "lecodes-web-frame", dist: "dist-page", entry: "page.js", vendored: "web-page", build: ["-c", "vite.page.config.ts"],
46
+ sources: ["hosts/web-frame/src", "hosts/web-frame/page", "hosts/web-player/src/protocol.ts"],
47
+ }
48
+
49
+ export const resolveWebRunner = (log: (message: string) => void = () => {}): WebRunner | null => {
50
+ const player = resolve(PLAYER, log)
51
+ return player ? { player, page: resolve(PAGE, log) } : null
52
+ }
53
+
54
+ const resolve = (built: Built, log: (message: string) => void): string | null => {
55
+ const has = (dir: string) => existsSync(join(dir, "index.html")) && existsSync(join(dir, built.entry))
56
+ const checkout = checkoutOf(built.pkg)
57
+ if (checkout) {
58
+ const dist = join(checkout, built.dist)
59
+ const repo = join(checkout, "..", "..")
60
+ if (!has(dist) || newest(built.sources.map((s) => join(repo, s))) > statSync(join(dist, built.entry)).mtimeMs) {
61
+ log(`Building ${built.pkg} (its sources are newer than its build)…`)
62
+ const done = spawnSync("bunx", ["vite", "build", ...built.build], { cwd: checkout, stdio: "pipe", encoding: "utf8", shell: process.platform === "win32" })
63
+ if (done.status !== 0) log(`${built.pkg} did not build — the browser gets the build that is there:\n${done.stderr || done.stdout}`)
64
+ }
65
+ if (has(dist)) return dist
66
+ }
67
+ const vendored = join(distRoot, "runtime", built.vendored)
68
+ return has(vendored) ? vendored : null
69
+ }
70
+
71
+ /** A package when it is a checkout (its sources are there), else null. */
72
+ const checkoutOf = (pkg: string): string | null => {
16
73
  try {
17
- const pkg = createRequire(import.meta.url).resolve("lecodes-viewer-lite/package.json")
18
- candidates.push(join(dirname(pkg), "dist-embed"))
19
- } catch { /* no package: standalone */ }
20
- for (const dir of candidates) if (existsSync(join(dir, "embed.html")) && existsSync(join(dir, "embed.js"))) return dir
21
- return null
74
+ const dir = dirname(createRequire(import.meta.url).resolve(`${pkg}/package.json`))
75
+ return existsSync(join(dir, "vite.config.ts")) && existsSync(join(dir, "src")) ? dir : null
76
+ } catch { return null }
77
+ }
78
+
79
+ /** The latest modification under the paths (0 when none exists). */
80
+ const newest = (paths: string[]): number => {
81
+ let latest = 0
82
+ const walk = (path: string) => {
83
+ let stat
84
+ try { stat = statSync(path) } catch { return }
85
+ if (!stat.isDirectory()) { latest = Math.max(latest, stat.mtimeMs); return }
86
+ for (const name of readdirSync(path)) if (name !== "node_modules" && name !== "ios" && name !== "android") walk(join(path, name))
87
+ }
88
+ for (const path of paths) walk(path)
89
+ return latest
22
90
  }
@@ -2,7 +2,7 @@ import { existsSync, readdirSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
3
  import { join, resolve } from "node:path"
4
4
  import { downloadReleaseArchive, fetchReleaseProduct, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
5
- import { CliError, note } from "./util"
5
+ import { CliError, note } from "../cli"
6
6
 
7
7
  /*
8
8
  * Filament's cmgen (environment tool) — the tool behind `lecodes assets sky`, which turns an
@@ -14,7 +14,7 @@ import { CliError, note } from "./util"
14
14
  * releases/versions.json — published by scripts/release/release-windows.ps1 from the desktop
15
15
  * build's in-tree filament host tools, so the KTX it writes always matches the engine the hosts
16
16
  * ship). Cache: ~/.lecodes/cmgen/cmgen-<version>/. LECODES_CMGEN overrides resolution entirely
17
- * (local dev: packages/desktop/build/creator-gl/filament/tools/cmgen/cmgen.exe).
17
+ * (local dev: hosts/desktop/build/creator-gl/filament/tools/cmgen/cmgen.exe).
18
18
  *
19
19
  * The CLI resolves it and hands the path to lecodes-assets through LECODES_CMGEN, so that package
20
20
  * stays network-free and still works standalone against a local build.
@@ -1,10 +1,10 @@
1
1
  import { execFileSync, spawn, spawnSync } from "node:child_process"
2
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, linkSync } from "node:fs"
3
3
  import { homedir, tmpdir } from "node:os"
4
4
  import { delimiter, join, resolve } from "node:path"
5
5
  import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
6
- import { CliError, note, warnErr } from "./util"
7
- import { makeStackDemangler } from "./compile/nativeStack"
6
+ import { CliError, note, warnErr } from "../cli"
7
+ import { makeStackDemangler } from "../compile/nativeStack"
8
8
 
9
9
  /*
10
10
  * The NATIVE renderer behind `lecodes render --desktop`: the LeCodes Desktop host (tgfx UI + Filament
@@ -15,7 +15,7 @@ import { makeStackDemangler } from "./compile/nativeStack"
15
15
  * natively). It downloads on first use from the same S3 release bucket the desktop distribution
16
16
  * publishes to (scripts/release/* → releases/versions.json), sha256-verified, into
17
17
  * ~/.lecodes/renderer/desktop-<version>/. LECODES_DESKTOP_EXE overrides resolution entirely
18
- * (local dev: point it at packages/desktop/build/lecodes-desktop.exe).
18
+ * (local dev: point it at hosts/desktop/build/lecodes-desktop.exe).
19
19
  *
20
20
  * Platforms: windows-x64 + linux (the ubuntu build) + macos-arm64. The macOS host is built and
21
21
  * published separately (the Apple repo's LeCodes Desktop.app, via scripts/release/release-macos.sh)
@@ -38,9 +38,10 @@ export const DESKTOP_RENDERER_VERSION = "1.1.0"
38
38
  export { semverNewer }
39
39
 
40
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. */
41
+ * (Filament + tgfx + creator-2d on one VkDevice), NOT a runtime switch — it has its own artifact, its
42
+ * own shaders, and a smaller feature set (no native-view plugins; the 2D engine joined it 2026-09-08,
43
+ * i.e. desktop releases after 1.4.1). Chosen per project via app.json `desktop.renderer`, or per run via `--renderer` /
44
+ * LECODES_DESKTOP_RENDERER. */
44
45
  export type DesktopRenderer = "gl" | "vulkan"
45
46
 
46
47
  export const DESKTOP_RENDERER_MIN_VERSION: Record<DesktopRenderer, string> = { gl: "0.0.0", vulkan: "1.4.0" }
@@ -57,8 +58,9 @@ const platformKey = (renderer: DesktopRenderer = "gl"): string => {
57
58
 
58
59
  const isMac = () => process.platform === "darwin"
59
60
 
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")
61
+ /** The host binary's file name — the same on every OS; on macOS it sits inside LeCodes Desktop.app
62
+ * (Contents/MacOS/lecodes-desktop, hosts/desktop/package-macos.sh). */
63
+ const exeName = () => (process.platform === "win32" ? "lecodes-desktop.exe" : "lecodes-desktop")
62
64
 
63
65
  /** Recursively locate the host binary inside an extracted archive (the zip nests a
64
66
  * `lecodes-desktop/` top-level folder; the tarballs may differ — search, don't assume). */
@@ -166,7 +168,7 @@ export const findMacHostExe = (): string | null => {
166
168
  const candidates = [
167
169
  ...["/Applications", join(homedir(), "Applications")]
168
170
  .flatMap((root) => ["LeCodes Desktop.app", "LeCodes.app"].map((app) => join(root, app))),
169
- ].map((app) => join(app, "Contents", "MacOS", "lecodes-app"))
171
+ ].map((app) => join(app, "Contents", "MacOS", "lecodes-desktop"))
170
172
  return candidates.find((exe) => existsSync(exe)) ?? null
171
173
  }
172
174
 
@@ -204,7 +206,7 @@ const resolveMacExe = async (selected: string): Promise<string> => {
204
206
  throw new CliError(
205
207
  `${e instanceof Error ? e.message : String(e)}\n` +
206
208
  " 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).",
209
+ " /Applications, or point LECODES_DESKTOP_EXE at a build (hosts/desktop/build-macos/lecodes-desktop, or an .app's Contents/MacOS/lecodes-desktop).",
208
210
  )
209
211
  }
210
212
  }
@@ -234,6 +236,38 @@ export const resolveDesktopExe = async (renderer: DesktopRenderer = "gl"): Promi
234
236
  return exe
235
237
  }
236
238
 
239
+ /** Which native host runs a bundle: the desktop (a window, a GPU, pixels) or the windowless server
240
+ * (hosts/desktop lecodes-server: the same engine on Filament's NOOP backend — the dedicated
241
+ * multiplayer server, a headless client, an offline simulation run; no UI hit-test, no pixels). */
242
+ export type HostKind = "desktop" | "server"
243
+
244
+ const serverExeName = () => (process.platform === "win32" ? "lecodes-server.exe" : "lecodes-server")
245
+
246
+ /** The windowless host: LECODES_SERVER_EXE override → the binary beside the resolved desktop host
247
+ * (one product, two executables — a release archive and a repo build both keep them side by side,
248
+ * sharing runtime/). A host older than the server's introduction has none beside it. */
249
+ export const resolveServerExe = async (renderer: DesktopRenderer = "gl"): Promise<string> => {
250
+ const override = process.env.LECODES_SERVER_EXE
251
+ if (override) {
252
+ if (!existsSync(override)) throw new CliError(`LECODES_SERVER_EXE points at "${override}" which doesn't exist.`)
253
+ return resolve(override)
254
+ }
255
+ // macOS: the server ships inside the .app beside the window host (Contents/MacOS/) since the
256
+ // in-repo Metal host (hosts/desktop/build-macos.sh); the "beside the desktop exe" rule holds.
257
+ const desktopExe = await resolveDesktopExe(renderer)
258
+ const exe = join(desktopExe, "..", serverExeName())
259
+ if (!existsSync(exe)) {
260
+ throw new CliError(
261
+ `No ${serverExeName()} beside ${desktopExe} — this desktop host predates the windowless server. ` +
262
+ "Update it (lecodes desktop update), or point LECODES_SERVER_EXE at a build (hosts/desktop/build[-linux|-macos]/lecodes-server).",
263
+ )
264
+ }
265
+ return exe
266
+ }
267
+
268
+ const resolveHostExe = (host: HostKind | undefined, renderer: DesktopRenderer | undefined): Promise<string> =>
269
+ host === "server" ? resolveServerExe(renderer) : resolveDesktopExe(renderer)
270
+
237
271
  /** `lecodes desktop update`: move the render host to the newest published release (never below the
238
272
  * CLI's pin — a manifest that somehow regressed under it is an error, not a downgrade). Saves the
239
273
  * selection so later `render --desktop` runs use it; the download is skipped when already cached. */
@@ -278,6 +312,8 @@ export type DesktopWindowOpts = {
278
312
  /** Which host binary to run (see DesktopRenderer). Must match the renderer the bundle's shaders
279
313
  * were staged for — resolve it once per command and pass the same value to both. */
280
314
  renderer?: DesktopRenderer
315
+ /** The desktop window (default) or the windowless server host (see HostKind). */
316
+ host?: HostKind
281
317
  width?: number
282
318
  height?: number
283
319
  minWidth?: number
@@ -304,8 +340,16 @@ export type DesktopWindowOpts = {
304
340
  * the block, forwarded verbatim as CREATOR_VIRTUAL_KEYBOARD JSON. A set env wins, like the mouse knob. */
305
341
  virtualKeyboard?: boolean | object
306
342
  /** Draw the host's frame counter in the corner (host ≥1.5.0; older hosts ignore the env). Costs
307
- * nothing to leave off and next to nothing on — the digits are rectangles, no UI behind them. */
343
+ * nothing to leave off and next to nothing on — the digits are rectangles, no UI behind them.
344
+ * On macOS too (host 2026-09-09; the CLI sets the same env there). */
308
345
  fps?: boolean
346
+ /** The host's profiler (CREATOR_PERF=1, host 2026-09-26): a `[perf]` line a second — GPU frame time from
347
+ * Filament's timer query, the CPU split, visible / total renderables, the view's switches — and the
348
+ * fps overlay's second row. docs/profiling.md in the core repo. */
349
+ perf?: boolean
350
+ /** app.json desktop.frameRate — a CAP on the host's render loop, "display" or a number; forwarded
351
+ * verbatim as CREATOR_FRAME_RATE (a set env wins). macOS host 2026-09-09; Windows ignores it. */
352
+ frameRate?: number | "display"
309
353
  /** Multiplayer launch facts for the host (`--server --port N --max-clients N` / `--connect ip:port`);
310
354
  * the SDK reads them through `Net.launch` and decides (docs/multiplayer-plan.md). */
311
355
  net?: { role: "server" | "client"; address?: string; port?: number; maxClients?: number }
@@ -339,6 +383,10 @@ const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
339
383
  env.CREATOR_RENDER_MAX = `${Math.round(opts.maxRenderSize[0])}x${Math.round(opts.maxRenderSize[1])}`
340
384
  }
341
385
  if (opts.fps) env.CREATOR_FPS = "1"
386
+ if (opts.perf) env.CREATOR_PERF = "1"
387
+ if (opts.frameRate !== undefined && process.env.CREATOR_FRAME_RATE === undefined) {
388
+ env.CREATOR_FRAME_RATE = String(opts.frameRate)
389
+ }
342
390
  if (opts.mouseEmulateTouch !== undefined && process.env.CREATOR_MOUSE_TOUCH === undefined) {
343
391
  env.CREATOR_MOUSE_TOUCH = opts.mouseEmulateTouch ? "1" : "0"
344
392
  }
@@ -366,6 +414,9 @@ export type DesktopRenderOptions = {
366
414
  /** Which host binary to run (see DesktopRenderer). Must match the renderer the bundle's shaders
367
415
  * were staged for — resolve it once per command and pass the same value to both. */
368
416
  renderer?: DesktopRenderer
417
+ /** The hidden desktop host (default: pixels, a PNG) or the windowless server host (no pixels — the
418
+ * run is judged by its output alone; `outPng` is ignored). */
419
+ host?: HostKind
369
420
  width: number
370
421
  height: number
371
422
  /** Frames to run before the dump — headroom for async loads (GLB, images, fonts). */
@@ -375,7 +426,7 @@ export type DesktopRenderOptions = {
375
426
  /** Forward the host's stdout/stderr (app console.log included) to stderr. */
376
427
  logs: boolean
377
428
  timeoutMs: number
378
- /** CREATOR_SCRIPT contents (the line format in packages/desktop/src/main.cpp `scriptStep`): the
429
+ /** CREATOR_SCRIPT contents (the line format in hosts/desktop/src/main.cpp `scriptStep`): the
379
430
  * host drives these commands and self-terminates when done; `frames` is then only a safety cap. */
380
431
  script?: string
381
432
  /** CREATOR_FIXED_DT: virtual app clock, ms per frame (0/undefined = wall clock). */
@@ -395,13 +446,29 @@ export type DesktopRenderOutcome = {
395
446
 
396
447
  /** Stage a compiled bundle for the host: app.js + the local-asset resources copied next to it
397
448
  * (the host's fetchLocal resolves them by basename relative to its cwd). Caller removes the dir. */
449
+ /** The staging dir the host runs from: app.js + every resource the bundle names, next to it (fetchLocal's
450
+ * fallback). A resource is HARD-LINKED when the temp dir shares the project's volume (the usual case:
451
+ * both on C:) and copied otherwise — a yard-sized project names hundreds of MB of models and textures,
452
+ * and copying them was seconds of "the window has not opened yet" on every run. The host only reads
453
+ * them, and the dir is removed after the run, so a link is as safe as a copy. */
398
454
  const stageBundle = (js: string, resources: { name: string, absPath?: string }[]): string => {
455
+ const t0 = performance.now()
399
456
  const runDir = mkdtempSync(join(tmpdir(), "lecodes-render-"))
400
457
  writeFileSync(join(runDir, "app.js"), js)
458
+ let files = 0, bytes = 0, copied = 0
401
459
  for (const r of resources) {
402
- if (r.absPath && existsSync(r.absPath) && statSync(r.absPath).isFile()) {
403
- copyFileSync(r.absPath, join(runDir, r.name))
404
- }
460
+ if (!r.absPath || !existsSync(r.absPath)) continue
461
+ const st = statSync(r.absPath)
462
+ if (!st.isFile()) continue
463
+ const dst = join(runDir, r.name)
464
+ try { linkSync(r.absPath, dst) }
465
+ catch { copyFileSync(r.absPath, dst); copied++ }
466
+ files++
467
+ bytes += st.size
468
+ }
469
+ const ms = performance.now() - t0
470
+ if (files > 0 && (ms > 250 || copied > 0)) {
471
+ note(`Staged ${files} resources (${(bytes / 1048576).toFixed(0)} MB${copied > 0 ? `, ${copied} copied` : ", linked"}) in ${(ms / 1000).toFixed(1)} s`)
405
472
  }
406
473
  return runDir
407
474
  }
@@ -428,7 +495,8 @@ export const runDesktopApp = async (
428
495
  resources: { name: string, absPath?: string }[],
429
496
  opts: DesktopWindowOpts = {},
430
497
  ): Promise<void> => {
431
- const exe = await resolveDesktopExe(opts.renderer)
498
+ const exe = await resolveHostExe(opts.host, opts.renderer)
499
+ const what = opts.host === "server" ? "server" : "desktop"
432
500
  const runDir = stageBundle(js, resources)
433
501
  try {
434
502
  const env = { ...process.env, ...windowEnv(opts) }
@@ -444,10 +512,15 @@ export const runDesktopApp = async (
444
512
  pipeThroughDemangler(child.stdout!, process.stdout, demangle)
445
513
  pipeThroughDemangler(child.stderr!, process.stderr, demangle)
446
514
  }
447
- child.on("error", (e) => fail(new CliError(`Can't run the desktop host: ${e.message}`)))
515
+ // Ctrl+C in the terminal: the child gets it too on a console (Windows) or via SIGINT (POSIX),
516
+ // and the server host tears its endpoint down on it; make sure a detached one is not left behind.
517
+ const onSigint = () => { child.kill("SIGINT") }
518
+ process.once("SIGINT", onSigint)
519
+ child.on("error", (e) => { process.off("SIGINT", onSigint); fail(new CliError(`Can't run the ${what} host: ${e.message}`)) })
448
520
  child.on("exit", (code) => {
521
+ process.off("SIGINT", onSigint)
449
522
  if (code === 0 || code === null) done() // null: killed (e.g. Ctrl+C reached the child)
450
- else fail(new CliError(`The desktop host exited with code ${code}.`))
523
+ else fail(new CliError(`The ${what} host exited with code ${code}.`))
451
524
  })
452
525
  })
453
526
  } finally {
@@ -479,30 +552,29 @@ export const runDesktopDev = async (url: string, opts: DesktopWindowOpts = {}):
479
552
  })
480
553
  }
481
554
 
482
- /** Run a compiled bundle in the hidden desktop host and dump the final frame as a PNG. */
555
+ /** Run a compiled bundle in the hidden desktop host and dump the final frame as a PNG — or, with
556
+ * `host: "server"`, in the windowless host, which paints nothing: the outcome is its output. */
483
557
  export const runDesktopRender = async (
484
558
  js: string,
485
559
  resources: { name: string, absPath?: string }[],
486
560
  opts: DesktopRenderOptions,
487
561
  ): Promise<DesktopRenderOutcome> => {
488
- if (isMac()) {
489
- throw new CliError(
490
- "`lecodes render --desktop` isn't available on macOS yet — the macOS host has no headless mode " +
491
- "(CREATOR_HIDDEN / CREATOR_DUMP_PNG). `lecodes desktop run` and `lecodes desktop build` do work there.",
492
- )
493
- }
494
- const exe = await resolveDesktopExe(opts.renderer)
562
+ const server = opts.host === "server"
563
+ // macOS: the in-repo Metal host (hosts/desktop, phase 11) carries the headless render contract like
564
+ // the other two platforms (CREATOR_HIDDEN / CREATOR_DUMP_PNG / CREATOR_SCRIPT); the old Swift shell
565
+ // did not — a cached one is refused by its missing lecodes-server, not here.
566
+ const exe = await resolveHostExe(opts.host, opts.renderer)
495
567
  const runDir = stageBundle(js, resources)
496
568
  try {
497
569
  const appJs = join(runDir, "app.js")
498
570
  const outAbs = resolve(opts.outPng)
499
571
  const env: Record<string, string | undefined> = {
500
572
  ...process.env,
501
- CREATOR_DUMP_PNG: outAbs,
502
573
  CREATOR_MAX_FRAMES: String(opts.frames),
503
574
  CREATOR_WINDOW_SIZE: `${opts.width}x${opts.height}`,
504
- CREATOR_HIDDEN: "1",
575
+ CREATOR_HIDDEN: "1", // a clean storage every run (and, on the desktop host, no window)
505
576
  }
577
+ if (!server) env.CREATOR_DUMP_PNG = outAbs
506
578
  if (opts.script !== undefined) {
507
579
  const scriptPath = join(runDir, "script.txt")
508
580
  writeFileSync(scriptPath, opts.script)
@@ -534,17 +606,17 @@ export const runDesktopRender = async (
534
606
  child.kill()
535
607
  fail(new CliError(`The native render timed out after ${opts.timeoutMs}ms (raise --timeout, or lower --frames).`))
536
608
  }, opts.timeoutMs)
537
- child.on("error", (e) => { clearTimeout(killer); fail(new CliError(`Can't run the desktop renderer: ${e.message}`)) })
609
+ child.on("error", (e) => { clearTimeout(killer); fail(new CliError(`Can't run the ${server ? "server" : "desktop"} host: ${e.message}`)) })
538
610
  child.on("exit", (code) => {
539
611
  clearTimeout(killer)
540
612
  const output = demangle(chunks.join(""))
541
613
  if (stopped) return done({ output, exitCode: 0 })
542
- if (code === 0 && existsSync(outAbs)) return done({ output, exitCode: code })
614
+ if (code === 0 && (server || existsSync(outAbs))) return done({ output, exitCode: code })
543
615
  const tail = demangle(chunks.slice(-80).join("")).trimEnd()
544
616
  const detail = tail.length > 0 ? `\n${tail}` : ""
545
617
  fail(new CliError(code === 0
546
618
  ? `The renderer exited cleanly but produced no PNG — did the app open a screen?${detail}`
547
- : `The renderer exited with code ${code}.${detail}`))
619
+ : `The ${server ? "server host" : "renderer"} exited with code ${code}.${detail}`))
548
620
  })
549
621
  })
550
622
  } finally {