lecodes-cli 1.0.0 → 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 (104) hide show
  1. package/dist/index.js +6211 -3649
  2. package/package.json +12 -8
  3. package/runtime/materials/lightmap.mat +115 -139
  4. package/runtime/materials/particles-quad.mat +6 -2
  5. package/runtime/materials/terrain-lightmap.mat +123 -0
  6. package/runtime/materials/terrain.mat +36 -55
  7. package/runtime/sdk-types.json +1 -1
  8. package/runtime/web/VERSION.json +5 -0
  9. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  10. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  11. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  12. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  13. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  14. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  15. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  16. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  17. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  18. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  19. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  20. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  21. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  22. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  23. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  24. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  25. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  26. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  27. package/runtime/web/index.html +36 -0
  28. package/runtime/web/player.js +21 -0
  29. package/runtime/web-page/index.html +41 -0
  30. package/runtime/web-page/page.css +1 -0
  31. package/runtime/web-page/page.js +31 -0
  32. package/src/cli/help.ts +25 -0
  33. package/src/cli/index.ts +7 -7
  34. package/src/commands/app/android.ts +3 -1
  35. package/src/commands/app/desktop.ts +1 -1
  36. package/src/commands/app/desktopMac.ts +10 -9
  37. package/src/commands/app/icon.ts +9 -6
  38. package/src/commands/app/index.ts +3 -1
  39. package/src/commands/app/shared.ts +13 -5
  40. package/src/commands/app/templates/android.ts +10 -11
  41. package/src/commands/app/templates/ios.ts +1 -1
  42. package/src/commands/assets.ts +1 -1
  43. package/src/commands/compile.ts +15 -2
  44. package/src/commands/design/index.ts +4 -4
  45. package/src/commands/design/snapshot.ts +3 -3
  46. package/src/commands/desktop.ts +5 -1
  47. package/src/commands/dev.ts +82 -29
  48. package/src/commands/index.ts +64 -60
  49. package/src/commands/init/index.ts +11 -15
  50. package/src/commands/init/templates.ts +64 -21
  51. package/src/commands/lightmap.ts +459 -200
  52. package/src/commands/navmesh.ts +2 -2
  53. package/src/commands/plugin.ts +46 -0
  54. package/src/commands/render.ts +22 -16
  55. package/src/commands/scene.ts +6 -1
  56. package/src/commands/serve.ts +58 -0
  57. package/src/commands/shaders/new.ts +1 -1
  58. package/src/commands/test.ts +402 -398
  59. package/src/commands/update/index.ts +3 -3
  60. package/src/compile/designCompile.ts +1 -1
  61. package/src/compile/headlessBundle.ts +133 -133
  62. package/src/compile/nativeStack.ts +1 -1
  63. package/src/compile/projectCompile.ts +10 -2
  64. package/src/compile/sceneCompile.ts +8 -22
  65. package/src/declarations/lecodes-3d-editor.d.ts +4 -0
  66. package/src/declarations/lecodes-assets.d.ts +1 -1
  67. package/src/declarations/{lecodes-renderer.d.ts → lecodes-headless.d.ts} +3 -3
  68. package/src/dev/clientTemplates.ts +18 -14
  69. package/src/dev/devServer.ts +75 -38
  70. package/src/dev/iosDev.ts +180 -0
  71. package/src/dev/webRunner.ts +82 -14
  72. package/src/hosts/cmgenTool.ts +1 -1
  73. package/src/hosts/desktopRenderer.ts +98 -27
  74. package/src/hosts/desktopScript.ts +150 -35
  75. package/src/hosts/distRoot.ts +1 -1
  76. package/src/hosts/matcTool.ts +1 -1
  77. package/src/hosts/peerInstall.ts +5 -5
  78. package/src/hosts/peers.ts +2 -2
  79. package/src/index.ts +4 -1
  80. package/src/plugin/contract.ts +438 -0
  81. package/src/plugin/emitKotlin.ts +357 -0
  82. package/src/plugin/emitSdk.ts +259 -0
  83. package/src/plugin/emitSwift.ts +336 -0
  84. package/src/plugin/emitWeb.ts +383 -0
  85. package/src/plugin/generate.ts +157 -0
  86. package/src/plugin/lines.ts +28 -0
  87. package/src/plugin/lower.ts +231 -0
  88. package/src/plugin/manifest.ts +192 -0
  89. package/src/project/ignore.ts +164 -163
  90. package/runtime/materials/lightmap-baked-lite.mat +0 -180
  91. package/runtime/materials/lightmap-baked.mat +0 -181
  92. package/runtime/scene-harness.json +0 -1
  93. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  94. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  95. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  96. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +0 -866
  97. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  98. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  99. package/runtime/web/assets/index-Q06c6oHx.js +0 -2
  100. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  101. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  102. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  103. package/runtime/web/embed.html +0 -32
  104. package/runtime/web/embed.js +0 -171
@@ -1,5 +1,5 @@
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"
@@ -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)
@@ -58,8 +58,9 @@ const platformKey = (renderer: DesktopRenderer = "gl"): string => {
58
58
 
59
59
  const isMac = () => process.platform === "darwin"
60
60
 
61
- /** The host binary's file name. macOS: the executable inside LeCodes Desktop.app. */
62
- 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")
63
64
 
64
65
  /** Recursively locate the host binary inside an extracted archive (the zip nests a
65
66
  * `lecodes-desktop/` top-level folder; the tarballs may differ — search, don't assume). */
@@ -167,7 +168,7 @@ export const findMacHostExe = (): string | null => {
167
168
  const candidates = [
168
169
  ...["/Applications", join(homedir(), "Applications")]
169
170
  .flatMap((root) => ["LeCodes Desktop.app", "LeCodes.app"].map((app) => join(root, app))),
170
- ].map((app) => join(app, "Contents", "MacOS", "lecodes-app"))
171
+ ].map((app) => join(app, "Contents", "MacOS", "lecodes-desktop"))
171
172
  return candidates.find((exe) => existsSync(exe)) ?? null
172
173
  }
173
174
 
@@ -205,7 +206,7 @@ const resolveMacExe = async (selected: string): Promise<string> => {
205
206
  throw new CliError(
206
207
  `${e instanceof Error ? e.message : String(e)}\n` +
207
208
  " No LeCodes Desktop.app is installed either — retry online (`lecodes desktop update`), install the DMG into\n" +
208
- " /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).",
209
210
  )
210
211
  }
211
212
  }
@@ -235,6 +236,38 @@ export const resolveDesktopExe = async (renderer: DesktopRenderer = "gl"): Promi
235
236
  return exe
236
237
  }
237
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
+
238
271
  /** `lecodes desktop update`: move the render host to the newest published release (never below the
239
272
  * CLI's pin — a manifest that somehow regressed under it is an error, not a downgrade). Saves the
240
273
  * selection so later `render --desktop` runs use it; the download is skipped when already cached. */
@@ -279,6 +312,8 @@ export type DesktopWindowOpts = {
279
312
  /** Which host binary to run (see DesktopRenderer). Must match the renderer the bundle's shaders
280
313
  * were staged for — resolve it once per command and pass the same value to both. */
281
314
  renderer?: DesktopRenderer
315
+ /** The desktop window (default) or the windowless server host (see HostKind). */
316
+ host?: HostKind
282
317
  width?: number
283
318
  height?: number
284
319
  minWidth?: number
@@ -305,8 +340,16 @@ export type DesktopWindowOpts = {
305
340
  * the block, forwarded verbatim as CREATOR_VIRTUAL_KEYBOARD JSON. A set env wins, like the mouse knob. */
306
341
  virtualKeyboard?: boolean | object
307
342
  /** Draw the host's frame counter in the corner (host ≥1.5.0; older hosts ignore the env). Costs
308
- * 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). */
309
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"
310
353
  /** Multiplayer launch facts for the host (`--server --port N --max-clients N` / `--connect ip:port`);
311
354
  * the SDK reads them through `Net.launch` and decides (docs/multiplayer-plan.md). */
312
355
  net?: { role: "server" | "client"; address?: string; port?: number; maxClients?: number }
@@ -340,6 +383,10 @@ const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
340
383
  env.CREATOR_RENDER_MAX = `${Math.round(opts.maxRenderSize[0])}x${Math.round(opts.maxRenderSize[1])}`
341
384
  }
342
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
+ }
343
390
  if (opts.mouseEmulateTouch !== undefined && process.env.CREATOR_MOUSE_TOUCH === undefined) {
344
391
  env.CREATOR_MOUSE_TOUCH = opts.mouseEmulateTouch ? "1" : "0"
345
392
  }
@@ -367,6 +414,9 @@ export type DesktopRenderOptions = {
367
414
  /** Which host binary to run (see DesktopRenderer). Must match the renderer the bundle's shaders
368
415
  * were staged for — resolve it once per command and pass the same value to both. */
369
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
370
420
  width: number
371
421
  height: number
372
422
  /** Frames to run before the dump — headroom for async loads (GLB, images, fonts). */
@@ -376,7 +426,7 @@ export type DesktopRenderOptions = {
376
426
  /** Forward the host's stdout/stderr (app console.log included) to stderr. */
377
427
  logs: boolean
378
428
  timeoutMs: number
379
- /** 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
380
430
  * host drives these commands and self-terminates when done; `frames` is then only a safety cap. */
381
431
  script?: string
382
432
  /** CREATOR_FIXED_DT: virtual app clock, ms per frame (0/undefined = wall clock). */
@@ -396,13 +446,29 @@ export type DesktopRenderOutcome = {
396
446
 
397
447
  /** Stage a compiled bundle for the host: app.js + the local-asset resources copied next to it
398
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. */
399
454
  const stageBundle = (js: string, resources: { name: string, absPath?: string }[]): string => {
455
+ const t0 = performance.now()
400
456
  const runDir = mkdtempSync(join(tmpdir(), "lecodes-render-"))
401
457
  writeFileSync(join(runDir, "app.js"), js)
458
+ let files = 0, bytes = 0, copied = 0
402
459
  for (const r of resources) {
403
- if (r.absPath && existsSync(r.absPath) && statSync(r.absPath).isFile()) {
404
- copyFileSync(r.absPath, join(runDir, r.name))
405
- }
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`)
406
472
  }
407
473
  return runDir
408
474
  }
@@ -429,7 +495,8 @@ export const runDesktopApp = async (
429
495
  resources: { name: string, absPath?: string }[],
430
496
  opts: DesktopWindowOpts = {},
431
497
  ): Promise<void> => {
432
- const exe = await resolveDesktopExe(opts.renderer)
498
+ const exe = await resolveHostExe(opts.host, opts.renderer)
499
+ const what = opts.host === "server" ? "server" : "desktop"
433
500
  const runDir = stageBundle(js, resources)
434
501
  try {
435
502
  const env = { ...process.env, ...windowEnv(opts) }
@@ -445,10 +512,15 @@ export const runDesktopApp = async (
445
512
  pipeThroughDemangler(child.stdout!, process.stdout, demangle)
446
513
  pipeThroughDemangler(child.stderr!, process.stderr, demangle)
447
514
  }
448
- 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}`)) })
449
520
  child.on("exit", (code) => {
521
+ process.off("SIGINT", onSigint)
450
522
  if (code === 0 || code === null) done() // null: killed (e.g. Ctrl+C reached the child)
451
- else fail(new CliError(`The desktop host exited with code ${code}.`))
523
+ else fail(new CliError(`The ${what} host exited with code ${code}.`))
452
524
  })
453
525
  })
454
526
  } finally {
@@ -480,30 +552,29 @@ export const runDesktopDev = async (url: string, opts: DesktopWindowOpts = {}):
480
552
  })
481
553
  }
482
554
 
483
- /** 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. */
484
557
  export const runDesktopRender = async (
485
558
  js: string,
486
559
  resources: { name: string, absPath?: string }[],
487
560
  opts: DesktopRenderOptions,
488
561
  ): Promise<DesktopRenderOutcome> => {
489
- if (isMac()) {
490
- throw new CliError(
491
- "`lecodes render --desktop` isn't available on macOS yet — the macOS host has no headless mode " +
492
- "(CREATOR_HIDDEN / CREATOR_DUMP_PNG). `lecodes desktop run` and `lecodes desktop build` do work there.",
493
- )
494
- }
495
- 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)
496
567
  const runDir = stageBundle(js, resources)
497
568
  try {
498
569
  const appJs = join(runDir, "app.js")
499
570
  const outAbs = resolve(opts.outPng)
500
571
  const env: Record<string, string | undefined> = {
501
572
  ...process.env,
502
- CREATOR_DUMP_PNG: outAbs,
503
573
  CREATOR_MAX_FRAMES: String(opts.frames),
504
574
  CREATOR_WINDOW_SIZE: `${opts.width}x${opts.height}`,
505
- CREATOR_HIDDEN: "1",
575
+ CREATOR_HIDDEN: "1", // a clean storage every run (and, on the desktop host, no window)
506
576
  }
577
+ if (!server) env.CREATOR_DUMP_PNG = outAbs
507
578
  if (opts.script !== undefined) {
508
579
  const scriptPath = join(runDir, "script.txt")
509
580
  writeFileSync(scriptPath, opts.script)
@@ -535,17 +606,17 @@ export const runDesktopRender = async (
535
606
  child.kill()
536
607
  fail(new CliError(`The native render timed out after ${opts.timeoutMs}ms (raise --timeout, or lower --frames).`))
537
608
  }, opts.timeoutMs)
538
- 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}`)) })
539
610
  child.on("exit", (code) => {
540
611
  clearTimeout(killer)
541
612
  const output = demangle(chunks.join(""))
542
613
  if (stopped) return done({ output, exitCode: 0 })
543
- if (code === 0 && existsSync(outAbs)) return done({ output, exitCode: code })
614
+ if (code === 0 && (server || existsSync(outAbs))) return done({ output, exitCode: code })
544
615
  const tail = demangle(chunks.slice(-80).join("")).trimEnd()
545
616
  const detail = tail.length > 0 ? `\n${tail}` : ""
546
617
  fail(new CliError(code === 0
547
618
  ? `The renderer exited cleanly but produced no PNG — did the app open a screen?${detail}`
548
- : `The renderer exited with code ${code}.${detail}`))
619
+ : `The ${server ? "server host" : "renderer"} exited with code ${code}.${detail}`))
549
620
  })
550
621
  })
551
622
  } finally {
@@ -1,15 +1,24 @@
1
1
  import { resolve } from "node:path"
2
2
  import { CliError } from "../cli"
3
+ // The evaluator lives with the headless runner (one source of truth); a relative source import,
4
+ // since lecodes-headless is an OPTIONAL peer of the CLI and --desktop must not require it.
5
+ import { evaluateDumpExpect, evaluatePaintExpect, parseTreeDump, type DumpSemanticExpect, type PaintExpect } from "../../../../runtime/headless/src/headless/paintDump"
3
6
 
4
7
  /*
5
8
  * `lecodes render --desktop --script`: the same JSON scenario a headless run takes, driven through
6
- * the NATIVE desktop host. The host has no semantic UI tree, so it can't resolve names or read text —
7
- * it takes a line-based command file (CREATOR_SCRIPT, see packages/desktop/src/main.cpp `scriptStep`)
8
- * with COORDINATES, and reports `[script] #<line> done <op>` on stdout as each command completes.
9
- * This module does both halves: translate the scenario into that file (refusing, with guidance, the
10
- * steps the host can't honour) and turn the host's stdout back into per-step results — `expect.log`
11
- * is evaluated here against the console lines seen up to each step's marker; the semantic expects
12
- * (`screen`/`node`/`text`/`value`) are reported as skipped, never silently passed.
9
+ * the NATIVE desktop host. The host takes a line-based command file (CREATOR_SCRIPT, the contract in
10
+ * hosts/desktop/src/host/script.h) and reports `[script] #<line> done <op>` on stdout as each command
11
+ * completes. A pointer target goes over as `X Y` (logical px) or as a quoted "name": the RUNTIME
12
+ * resolves it exactly as the headless session does (name → exact text → text substring, creator-pkg
13
+ * pkgFindNode) and the renderer places it — so a flow written by name runs on the desktop too; `fill`
14
+ * / `scroll` / `clip` are host commands of their own. This module does both halves: translate the
15
+ * scenario into that file and turn the host's stdout back into per-step results — `expect.log` is
16
+ * evaluated here against the console lines seen up to each step's marker; `expect.paint` and the
17
+ * semantic expects (`text` / `node` / `screen` / `value`) against the tree dump the host writes right
18
+ * after the step (the same rules as the headless session, paintDump.ts). Only `expect.audio` (the
19
+ * headless recorder's) and the `json` output (the headless renderer's shape) are reported as
20
+ * skipped, never silently passed. The windowless host (`--sim`) has no UI at all: names, hover,
21
+ * typing, fill, scroll and screenshots are refused there.
13
22
  */
14
23
 
15
24
  type Point = { x: number, y: number }
@@ -24,9 +33,16 @@ export type DesktopPlanStep = {
24
33
  /** Script line numbers this step emitted; the LAST one's `done` marker completes the step. */
25
34
  lines: number[]
26
35
  expectLog?: string
27
- /** Expect keys the native host can't evaluate (no semantic tree) — reported as skipped. */
36
+ /** expect.paint and the semantic expects: the host writes the tree dump to `paintDump` (a `dump`
37
+ * line) right before the step's marker; the assertions are evaluated here against that file. */
38
+ expectPaint?: PaintExpect
39
+ expectSemantic?: DumpSemanticExpect
40
+ paintDump?: string
41
+ /** A `dump` output step: the file the host wrote. */
42
+ dump?: string
43
+ /** Expect keys the native host can't evaluate (`audio`: the headless recorder's) — reported as skipped. */
28
44
  skippedExpect: string[]
29
- /** Outputs the native host can't produce (`json`). */
45
+ /** Outputs the native host can't produce (`json`: the headless renderer's semantic shape — use `dump`). */
30
46
  skippedOutputs: string[]
31
47
  screenshot?: string
32
48
  }
@@ -39,31 +55,41 @@ export type DesktopPlan = {
39
55
  frameCap: number
40
56
  }
41
57
 
42
- const ACTIONS = ["tap", "type", "fill", "scroll", "wait", "frames", "drag", "hold", "key", "keyDown", "keyUp", "look", "mouseTo", "wheel", "stick", "gamepad", "camera", "view"] as const
43
- const SEMANTIC_EXPECT = ["text", "notText", "node", "notNode", "screen", "value", "audio"]
58
+ const ACTIONS = ["tap", "type", "fill", "scroll", "wait", "frames", "drag", "hold", "hover", "key", "keyDown", "keyUp", "look", "mouseTo", "wheel", "stick", "gamepad", "camera", "view"] as const
59
+ const SEMANTIC_EXPECT = ["text", "notText", "node", "notNode", "screen", "value"]
60
+ const UNSUPPORTED_EXPECT = ["audio"]
44
61
  const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
45
62
  const isVec3 = (v: unknown): v is [number, number, number] => Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === "number")
46
63
 
47
- const pt = (p: Point) => `(${p.x},${p.y})`
64
+ type Target = Point | string
65
+ const pt = (p: Target) => typeof p === "string" ? p : `(${p.x},${p.y})`
66
+ /** A target as the host's script reads it: `X Y`, or a quoted "name" (`"` and `\` escaped). */
67
+ const wire = (p: Target) => typeof p === "string" ? `"${p.replace(/[\\"]/g, (ch) => "\\" + ch)}"` : `${p.x} ${p.y}`
68
+ /** `type` text as the host's `type` line takes it: the rest of the line, `\n` = enter, `\\` = a backslash. */
69
+ const wireText = (s: string) => s.replace(/\\/g, "\\\\").replace(/\r?\n/g, "\\n")
48
70
 
49
71
  /** Translate scenario steps into the host's command file. `headroomFrames` runs first so the app
50
- * can load (the `--frames` value); screenshots land under `outDir`. Throws a CliError for steps the
51
- * native host can't honour (named targets, typing, scrolling, clips). */
52
- export const translateScenario = (rawSteps: unknown, opts: { outDir: string, headroomFrames: number }): DesktopPlan => {
72
+ * can load (the `--frames` value); screenshots land under `outDir`. `sim` = the windowless host
73
+ * (`--sim`): it has no UI, so names, `hover`, `type`, `fill` and `scroll` are refused there, it
74
+ * paints nothing, so per-step screenshots are refused too, and a tap/drag/hold reaches the SCENE's
75
+ * touch input directly. */
76
+ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, headroomFrames: number, sim?: boolean }): DesktopPlan => {
53
77
  if (!Array.isArray(rawSteps)) throw new CliError(`"steps" must be an array of step objects.`)
54
78
  const lines: string[] = []
55
79
  const steps: DesktopPlanStep[] = []
56
80
  let budgetMs = 0
57
81
  const emit = (line: string): number => { lines.push(line); return lines.length }
58
- emit(`# generated by lecodes render --desktop --script`)
82
+ emit(`# generated by lecodes render ${opts.sim ? "--sim" : "--desktop"} --script`)
59
83
  emit(`frames ${Math.max(0, opts.headroomFrames)}`)
60
84
 
61
- const coord = (at: string, key: string, v: unknown): Point => {
85
+ const target = (at: string, key: string, v: unknown): Target => {
62
86
  if (isPoint(v)) return v
63
87
  if (typeof v === "string") {
64
- throw new CliError(`${at}: "${key}": "${v}" — the native host can't resolve names; give --desktop scripts { "x", "y" } coordinates (logical px), or run the scenario headless (without --desktop).`)
88
+ if (v.length === 0) throw new CliError(`${at}: "${key}" is an empty name.`)
89
+ if (opts.sim) throw new CliError(`${at}: "${key}": "${v}" — the windowless host has no UI to resolve a name in; give --sim scripts { "x", "y" } coordinates (scene input), or run the scenario with --desktop / headless.`)
90
+ return v
65
91
  }
66
- throw new CliError(`${at}: "${key}" must be { "x", "y" }.`)
92
+ throw new CliError(`${at}: "${key}" must be a name or { "x", "y" }.`)
67
93
  }
68
94
  const num = (at: string, key: string, v: unknown, def?: number): number => {
69
95
  if (v === undefined && def !== undefined) return def
@@ -84,10 +110,10 @@ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, hea
84
110
  const parts: string[] = []
85
111
  const action = actions[0]
86
112
 
87
- if (action === "type" || action === "fill" || action === "scroll") {
88
- throw new CliError(`${at}: "${action}" isn't available with --desktop (the native host has no semantic tree / soft keyboard) — run this scenario headless, or drive the gesture with "drag" coordinates.`)
113
+ if ((action === "type" || action === "hover" || action === "fill" || action === "scroll") && opts.sim) {
114
+ throw new CliError(`${at}: "${action}" isn't available with --sim (the windowless host has no UI) — run this scenario with --desktop or headless.`)
89
115
  }
90
- if ((action === "tap" || action === "drag" || action === "hold") && inspecting) {
116
+ if ((action === "tap" || action === "drag" || action === "hold" || action === "hover" || action === "type") && inspecting) {
91
117
  throw new CliError(`${at}: "${action}" while an inspection camera is active — the app's hit-testing uses its own camera, not the one in the shot. Add { "view": "off" } (or { "camera": "off" }) first.`)
92
118
  }
93
119
  if (action === "view") {
@@ -112,19 +138,52 @@ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, hea
112
138
  }
113
139
  if (s.fov !== undefined && action !== "view") throw new CliError(`${at}: "fov" only goes with "view" (inside "camera" use camera.fov).`)
114
140
  if (action === "tap") {
115
- const p = coord(at, "tap", s.tap)
116
- plan.lines.push(emit(`tap ${p.x} ${p.y}`)); budgetMs += 4 * FRAME_MS
141
+ const p = target(at, "tap", s.tap)
142
+ plan.lines.push(emit(`tap ${wire(p)}`)); budgetMs += 4 * FRAME_MS
117
143
  parts.push(`tap ${pt(p)}`)
144
+ } else if (action === "type") {
145
+ // The host types into the FOCUSED input: a tap on the target focuses it, then the characters go
146
+ // in one by one (appended — the headless session sets the value; on an empty input they agree).
147
+ const p = target(at, "type", s.type)
148
+ if (typeof s.text !== "string") throw new CliError(`${at}: "type" needs a "text" string.`)
149
+ plan.lines.push(emit(`tap ${wire(p)}`))
150
+ plan.lines.push(emit(`type ${wireText(s.text)}`)); budgetMs += 6 * FRAME_MS
151
+ parts.push(`type ${pt(p)}="${s.text}"`)
118
152
  } else if (action === "drag") {
119
- const a = coord(at, "drag", s.drag), b = coord(at, "to", s.to)
153
+ const a = target(at, "drag", s.drag), b = target(at, "to", s.to)
120
154
  const ms = num(at, "ms", s.ms, 300)
121
- plan.lines.push(emit(`drag ${a.x} ${a.y} ${b.x} ${b.y} ${Math.round(ms)}`)); budgetMs += ms + 4 * FRAME_MS
155
+ plan.lines.push(emit(`drag ${wire(a)} ${wire(b)} ${Math.round(ms)}`)); budgetMs += ms + 4 * FRAME_MS
122
156
  parts.push(`drag ${pt(a)} → ${pt(b)} over ${ms}ms`)
123
157
  } else if (action === "hold") {
124
- const p = coord(at, "hold", s.hold)
158
+ const p = target(at, "hold", s.hold)
125
159
  const ms = num(at, "ms", s.ms)
126
- plan.lines.push(emit(`hold ${p.x} ${p.y} ${Math.round(ms)}`)); budgetMs += ms + 4 * FRAME_MS
160
+ plan.lines.push(emit(`hold ${wire(p)} ${Math.round(ms)}`)); budgetMs += ms + 4 * FRAME_MS
127
161
  parts.push(`hold ${pt(p)} ${ms}ms`)
162
+ } else if (action === "hover") {
163
+ // The cursor over a node: Input.mouse.position + the UI hover (`$hovered`, onMouseEnter). The
164
+ // host completes it two frames later, after the enter / leave crossed into JS.
165
+ const p = target(at, "hover", s.hover)
166
+ plan.lines.push(emit(`mouseto ${wire(p)}`)); budgetMs += 3 * FRAME_MS
167
+ parts.push(`hover ${pt(p)}`)
168
+ } else if (action === "fill") {
169
+ const f = s.fill
170
+ if (typeof f !== "object" || f === null || ((f as { value?: unknown }).value !== undefined && typeof (f as { value?: unknown }).value !== "string")) {
171
+ throw new CliError(`${at}: "fill" must be {} or { "value": string }.`)
172
+ }
173
+ const value = (f as { value?: string }).value
174
+ plan.lines.push(emit(value !== undefined ? `fill ${wireText(value)}` : "fill")); budgetMs += 8 * 6 * FRAME_MS // room for a form of inputs
175
+ parts.push(value !== undefined ? `fill inputs ("${value}")` : "fill inputs")
176
+ } else if (action === "scroll") {
177
+ // `{ "scroll": 300 }` = the first scrollable; `{ "scroll": "feed", "by": 300 }` = the named one's.
178
+ if (typeof s.scroll === "number") {
179
+ if (s.by !== undefined) throw new CliError(`${at}: "by" only goes with a named "scroll".`)
180
+ plan.lines.push(emit(`scroll ${s.scroll}`)); parts.push(`scroll ${s.scroll}`)
181
+ } else if (typeof s.scroll === "string") {
182
+ if (typeof s.by !== "number") throw new CliError(`${at}: scrolling a named target needs "by" (px).`)
183
+ if (s.scroll.length === 0) throw new CliError(`${at}: "scroll" is an empty name.`)
184
+ plan.lines.push(emit(`scroll ${s.by} ${wire(s.scroll)}`)); parts.push(`scroll ${s.scroll} by ${s.by}`)
185
+ } else throw new CliError(`${at}: "scroll" must be a number (px) or a name (+ "by").`)
186
+ budgetMs += 2 * FRAME_MS
128
187
  } else if (action === "key") {
129
188
  if (typeof s.key !== "string") throw new CliError(`${at}: "key" must be a KeyboardEvent.code string.`)
130
189
  const ms = num(at, "ms", s.ms)
@@ -144,7 +203,7 @@ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, hea
144
203
  } else if (action === "mouseTo") {
145
204
  const v = s.mouseTo
146
205
  if (!Array.isArray(v) || v.length !== 2 || !v.every((n: unknown) => typeof n === "number")) throw new CliError(`${at}: "mouseTo" must be [x, y] (logical px).`)
147
- plan.lines.push(emit(`mouseto ${v[0]} ${v[1]}`)); budgetMs += FRAME_MS
206
+ plan.lines.push(emit(`mouseto ${v[0]} ${v[1]}`)); budgetMs += 3 * FRAME_MS
148
207
  parts.push(`mouseTo ${v[0]},${v[1]}`)
149
208
  } else if (action === "wheel") {
150
209
  const v = s.wheel
@@ -184,29 +243,71 @@ export const translateScenario = (rawSteps: unknown, opts: { outDir: string, hea
184
243
  if (typeof s.expect !== "object" || s.expect === null) throw new CliError(`${at}: "expect" must be an object.`)
185
244
  const e = s.expect as Record<string, unknown>
186
245
  const bits: string[] = []
246
+ let needDump = false
187
247
  for (const k of Object.keys(e)) {
188
248
  if (k === "log") {
189
249
  if (typeof e.log !== "string") throw new CliError(`${at}: expect.log must be a string.`)
190
250
  plan.expectLog = e.log
191
251
  bits.push(`log~"${e.log}"`)
252
+ } else if (k === "paint") {
253
+ if (typeof e.paint !== "object" || e.paint === null) throw new CliError(`${at}: expect.paint must be an object of node names.`)
254
+ plan.expectPaint = e.paint as PaintExpect
255
+ needDump = true
256
+ bits.push(`paint(${Object.keys(e.paint).join(", ")})`)
192
257
  } else if (SEMANTIC_EXPECT.includes(k)) {
258
+ if (k === "value") {
259
+ const v = e.value as { target?: unknown, equals?: unknown } | null
260
+ if (typeof v !== "object" || v === null || typeof v.target !== "string" || typeof v.equals !== "string") throw new CliError(`${at}: expect.value needs { "target": string, "equals": string }.`)
261
+ } else if (typeof e[k] !== "string") throw new CliError(`${at}: expect.${k} must be a string.`)
262
+ plan.expectSemantic = { ...(plan.expectSemantic ?? {}), [k]: e[k] }
263
+ needDump = true
264
+ const v = e[k]
265
+ bits.push(k === "screen" ? `screen=${v}` : k === "node" ? `node=${v}` : k === "notNode" ? `!node=${v}` : k === "text" ? `text~"${v}"` : k === "notText" ? `!text~"${v}"` : `${(v as { target: string }).target}="${(v as { equals: string }).equals}"`)
266
+ } else if (UNSUPPORTED_EXPECT.includes(k)) {
193
267
  plan.skippedExpect.push(k)
194
268
  } else {
195
269
  throw new CliError(`${at}: unknown expect key "${k}".`)
196
270
  }
197
271
  }
272
+ if (needDump) {
273
+ plan.paintDump = resolve(opts.outDir, `.dump-${i}.json`)
274
+ // The tree dump lands one frame after the action settled, like a screenshot.
275
+ plan.lines.push(emit(`frames 1`))
276
+ plan.lines.push(emit(`dump ${plan.paintDump}`)); budgetMs += 2 * FRAME_MS
277
+ }
198
278
  if (bits.length > 0) parts.push(`expect ${bits.join(" · ")}`)
199
279
  }
200
- if (s.clip !== undefined) throw new CliError(`${at}: "clip" isn't available with --desktop (the native host dumps the whole window).`)
280
+ if (s.clip !== undefined && s.screenshot === undefined) throw new CliError(`${at}: "clip" only goes with "screenshot".`)
201
281
  if (s.screenshot !== undefined) {
202
282
  if (typeof s.screenshot !== "string") throw new CliError(`${at}: "screenshot" must be a path.`)
283
+ if (opts.sim) throw new CliError(`${at}: "screenshot" isn't available with --sim (the windowless host paints nothing) — judge the run by expect.log, or run it with --desktop for pixels.`)
203
284
  plan.screenshot = resolve(opts.outDir, s.screenshot)
204
285
  // The shot lands AFTER the action settles (two frames), like the headless act → capture order.
286
+ // A clip (a node's box or a logical-px rect) is set for the shot and cleared after it.
287
+ const clip = s.clip
288
+ if (clip !== undefined) {
289
+ if (typeof clip === "string") {
290
+ if (clip.length === 0) throw new CliError(`${at}: "clip" is an empty name.`)
291
+ plan.lines.push(emit(`frames 2`)) // the node's box after the action settled
292
+ plan.lines.push(emit(`clip ${wire(clip)}`))
293
+ } else if (typeof clip === "object" && clip !== null && ["x", "y", "width", "height"].every((k) => typeof (clip as Record<string, unknown>)[k] === "number")) {
294
+ const r = clip as { x: number, y: number, width: number, height: number }
295
+ plan.lines.push(emit(`clip ${r.x} ${r.y} ${r.width} ${r.height}`))
296
+ } else throw new CliError(`${at}: "clip" must be a node name or { "x", "y", "width", "height" }.`)
297
+ }
205
298
  plan.lines.push(emit(`frames 2`))
206
- plan.lines.push(emit(`shot ${plan.screenshot}`)); budgetMs += 3 * FRAME_MS
207
- parts.push(`→ ${s.screenshot}`)
299
+ plan.lines.push(emit(`shot ${plan.screenshot}`)); budgetMs += 5 * FRAME_MS
300
+ if (clip !== undefined) plan.lines.push(emit(`clip off`))
301
+ parts.push(`→ ${s.screenshot}${clip !== undefined ? ` (clip ${typeof clip === "string" ? clip : "rect"})` : ""}`)
208
302
  }
209
303
  if (s.json !== undefined) plan.skippedOutputs.push("json")
304
+ if (s.dump !== undefined) {
305
+ if (typeof s.dump !== "string") throw new CliError(`${at}: "dump" must be a path.`)
306
+ plan.dump = resolve(opts.outDir, s.dump)
307
+ plan.lines.push(emit(`frames 1`))
308
+ plan.lines.push(emit(`dump ${plan.dump}`)); budgetMs += 2 * FRAME_MS
309
+ parts.push(`→ ${s.dump} (dump)`)
310
+ }
210
311
  if (plan.lines.length === 0) {
211
312
  // An expect-only step still needs a marker to anchor its evaluation.
212
313
  plan.lines.push(emit(`frames 1`)); budgetMs += FRAME_MS
@@ -230,7 +331,7 @@ export type DesktopRunResult = {
230
331
 
231
332
  /** Turn the host's stdout into per-step results. Console lines are every non-`[script]` line the
232
333
  * host printed up to a step's completion marker (the core prints `console.log` as `[DEBUG] …`). */
233
- export const evaluateDesktopRun = (plan: DesktopPlan, output: string): DesktopRunResult => {
334
+ export const evaluateDesktopRun = (plan: DesktopPlan, output: string, readFile: (path: string) => string | null = () => null): DesktopRunResult => {
234
335
  const lines = output.split(/\r?\n/)
235
336
  const doneAt = new Map<number, { at: number, error?: string }>()
236
337
  const consoleLines: string[] = []
@@ -251,8 +352,8 @@ export const evaluateDesktopRun = (plan: DesktopPlan, output: string): DesktopRu
251
352
  let ok = true
252
353
  for (const st of plan.steps) {
253
354
  const skipped = [
254
- ...st.skippedExpect.map((k) => `expect.${k} (no semantic tree on --desktop)`),
255
- ...st.skippedOutputs.map((k) => `${k} output (no semantic tree on --desktop)`),
355
+ ...st.skippedExpect.map((k) => `expect.${k} (the headless audio recorder's — not on a native host)`),
356
+ ...st.skippedOutputs.map((k) => `${k} output (the headless renderer's shape — use "dump" on a native host)`),
256
357
  ]
257
358
  let error: string | undefined
258
359
  let cutoff = consoleLines.length
@@ -262,6 +363,20 @@ export const evaluateDesktopRun = (plan: DesktopPlan, output: string): DesktopRu
262
363
  if (d.error) { error = error ?? d.error; break }
263
364
  cutoff = d.at
264
365
  }
366
+ if (!error && (st.expectPaint !== undefined || st.expectSemantic !== undefined)) {
367
+ const text = st.paintDump ? readFile(st.paintDump) : null
368
+ if (text === null) error = "the host wrote no tree dump for the expect (an older host without the `dump` command?)"
369
+ else {
370
+ try {
371
+ const dump = parseTreeDump(text)
372
+ const failures = [
373
+ ...(st.expectSemantic !== undefined ? evaluateDumpExpect(dump, st.expectSemantic) : []),
374
+ ...(st.expectPaint !== undefined ? evaluatePaintExpect(dump, st.expectPaint) : []),
375
+ ]
376
+ if (failures.length > 0) error = failures.join("; ")
377
+ } catch (e) { error = `expect: ${e instanceof Error ? e.message : String(e)}` }
378
+ }
379
+ }
265
380
  if (!error && st.expectLog !== undefined) {
266
381
  const seen = consoleLines.slice(0, cutoff)
267
382
  if (!seen.some((l) => l.includes(st.expectLog!))) {
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"
8
8
  * - npm package: the bundle is dist/index.js, companions at ../runtime.
9
9
  * - monorepo dev: this module runs from src/hosts/, so the package root is two levels up. The
10
10
  * package.json marker matters here — runtime/ is gitignored and absent until vendor-runtime runs,
11
- * and callers also resolve siblings from the root (../projects, ../creator-gl).
11
+ * and callers also resolve siblings from the root (../../engines/gl).
12
12
  * - standalone binary (`bun build --compile`, see scripts/build-bin.ts): import.meta.url points into
13
13
  * the virtual bunfs archive which does NOT exist on disk, so the companions ship in the release
14
14
  * archive NEXT TO the executable and are resolved via process.execPath. */
@@ -14,7 +14,7 @@ import { CliError, note } from "../cli"
14
14
  * releases/versions.json — published by scripts/release/release-windows.ps1 / release-ubuntu.sh
15
15
  * from the desktop build's in-tree filament host tools, so it always matches the engines the
16
16
  * hosts ship). Cache: ~/.lecodes/matc/matc-<version>/. LECODES_MATC overrides resolution entirely
17
- * (local dev: point it at packages/desktop/build/creator-gl/filament/tools/matc/matc.exe).
17
+ * (local dev: point it at hosts/desktop/build/creator-gl/filament/tools/matc/matc.exe).
18
18
  *
19
19
  * No version pin: a filamat's compatibility is tied to the vendored Filament, which changes rarely —
20
20
  * resolution takes the newest cached copy, else downloads the newest release PUBLISHED FOR THIS
@@ -8,7 +8,7 @@ import { CliError, c, confirm, log, note } from "../cli"
8
8
 
9
9
  /*
10
10
  * Peers install themselves on first use. `lecodes render` on a machine with only the CLI asks once,
11
- * then puts lecodes-renderer (+ @napi-rs/canvas for PNG) into ~/.lecodes/peers and loads it from
11
+ * then puts lecodes-headless (+ @napi-rs/canvas for PNG) into ~/.lecodes/peers and loads it from
12
12
  * there — the same pattern as matc and the desktop host (downloaded into ~/.lecodes on first use),
13
13
  * applied to npm packages. Why a private prefix and not `npm install -g`: no sudo / system prefix
14
14
  * to guess, and the runtime-set peer is installed at EXACTLY this CLI's version — the compiler and the
@@ -31,9 +31,9 @@ const readJson = (path: string): any => { try { return JSON.parse(readFileSync(p
31
31
  /** The npm spec a peer installs at: the renderer at exactly this CLI's version (one ABI set), canvas
32
32
  * at the range the installed renderer declares, the editors / assets at this CLI's peer ranges. */
33
33
  export const peerSpec = (name: string): string => {
34
- if (name === "lecodes-renderer") return `lecodes-renderer@${pkg.version}`
34
+ if (name === "lecodes-headless") return `lecodes-headless@${pkg.version}`
35
35
  if (name === "@napi-rs/canvas") {
36
- const renderer = readJson(join(peerDirInPrefix("lecodes-renderer"), "package.json"))
36
+ const renderer = readJson(join(peerDirInPrefix("lecodes-headless"), "package.json"))
37
37
  const range = renderer?.peerDependencies?.["@napi-rs/canvas"] ?? renderer?.optionalDependencies?.["@napi-rs/canvas"] ?? CANVAS_RANGE_FALLBACK
38
38
  return `@napi-rs/canvas@${range}`
39
39
  }
@@ -42,7 +42,7 @@ export const peerSpec = (name: string): string => {
42
42
  }
43
43
 
44
44
  /** Peers that only make sense together: the renderer's PNG output needs canvas next to it. */
45
- const companions = (name: string): string[] => name === "lecodes-renderer" ? ["lecodes-renderer", "@napi-rs/canvas"] : [name]
45
+ const companions = (name: string): string[] => name === "lecodes-headless" ? ["lecodes-headless", "@napi-rs/canvas"] : [name]
46
46
 
47
47
  const onPath = (cmd: string): boolean => {
48
48
  try { return spawnSync(cmd, ["--version"], { stdio: "ignore", shell: process.platform === "win32" }).status === 0 } catch { return false }
@@ -66,7 +66,7 @@ export const installPeers = async (names: string[], why: string, opts: { yes?: b
66
66
  }, null, 2) + "\n")
67
67
  }
68
68
  // The renderer must be in place before canvas's range can be read off it — install in order.
69
- const ordered = [...names].sort((a, b) => (a === "lecodes-renderer" ? -1 : b === "lecodes-renderer" ? 1 : 0))
69
+ const ordered = [...names].sort((a, b) => (a === "lecodes-headless" ? -1 : b === "lecodes-headless" ? 1 : 0))
70
70
  const specsNow = ordered.map(peerSpec)
71
71
 
72
72
  const yes = opts.yes === true || !!process.env.LECODES_YES