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,15 +1,24 @@
1
1
  import { resolve } from "node:path"
2
- import { CliError } from "./util"
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!))) {
@@ -5,16 +5,20 @@ import { fileURLToPath } from "node:url"
5
5
  /* Root of the CLI's on-disk companion files (runtime/ with the vendored SDK source + type bundle).
6
6
  *
7
7
  * Two distribution shapes:
8
- * - npm package / monorepo dev: this module lives in dist/ (or src/), companions at ../runtime —
9
- * resolve relative to import.meta.url as before.
8
+ * - npm package: the bundle is dist/index.js, companions at ../runtime.
9
+ * - monorepo dev: this module runs from src/hosts/, so the package root is two levels up. The
10
+ * package.json marker matters here — runtime/ is gitignored and absent until vendor-runtime runs,
11
+ * and callers also resolve siblings from the root (../../engines/gl).
10
12
  * - standalone binary (`bun build --compile`, see scripts/build-bin.ts): import.meta.url points into
11
13
  * the virtual bunfs archive which does NOT exist on disk, so the companions ship in the release
12
14
  * archive NEXT TO the executable and are resolved via process.execPath. */
13
15
  export const distRoot = (() => {
16
+ const here = dirname(fileURLToPath(import.meta.url))
14
17
  const candidates = [
15
- join(dirname(fileURLToPath(import.meta.url)), ".."), // package root (dist/.. or src/..)
16
- dirname(process.execPath), // standalone binary: next to the exe
18
+ join(here, ".."), // npm package: dist/..
19
+ join(here, "..", ".."), // monorepo dev: src/hosts/../..
20
+ dirname(process.execPath), // standalone binary: next to the exe
17
21
  ]
18
- for (const c of candidates) if (existsSync(join(c, "runtime"))) return c
22
+ for (const c of candidates) if (existsSync(join(c, "runtime")) || existsSync(join(c, "package.json"))) return c
19
23
  return candidates[0]
20
24
  })()
@@ -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 matc (material compiler) — the tool behind LOCAL shader compile: `lecodes dev`
@@ -14,7 +14,7 @@ import { CliError, note } from "./util"
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
@@ -2,13 +2,13 @@ import { spawnSync } from "node:child_process"
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
3
  import { join } from "node:path"
4
4
  import { pathToFileURL } from "node:url"
5
- import pkg from "../package.json"
5
+ import pkg from "../../package.json"
6
6
  import { peerDirInPrefix, peersDir, peerVersionGlobal } from "./peers"
7
- import { CliError, c, confirm, log, note } from "./util"
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
@@ -10,7 +10,7 @@ import { dirname, join, resolve } from "node:path"
10
10
  * peers (a project-local `-D` install is invisible to it).
11
11
  * prefix ~/.lecodes/peers (LECODES_PEERS_DIR) — the CLI's own install dir, filled ON FIRST USE by
12
12
  * peerInstall.ts when a command needs a peer that isn't there: no admin rights, and the
13
- * runtime-set peer (lecodes-renderer) is pinned to THIS CLI's version, so the compiler and
13
+ * runtime-set peer (lecodes-headless) is pinned to THIS CLI's version, so the compiler and
14
14
  * the headless host can't drift apart. `@napi-rs/canvas` (PNG output) rides along with it.
15
15
  *
16
16
  * Global wins when both exist (it is what the user chose by hand). `--version` reports each peer's
@@ -18,7 +18,7 @@ import { dirname, join, resolve } from "node:path"
18
18
  * errors or a design screen rendering empty — is visible at a glance. The probe is an export subpath
19
19
  * that resolves to a real file in BOTH the published package and the workspace source. */
20
20
  export const OPTIONAL_PEERS: [name: string, probe: string][] = [
21
- ["lecodes-renderer", "lecodes-renderer/headless"],
21
+ ["lecodes-headless", "lecodes-headless/headless"],
22
22
  ["lecodes-design", "lecodes-design/server"],
23
23
  ["lecodes-3d-editor", "lecodes-3d-editor/server"],
24
24
  ["lecodes-assets", "lecodes-assets/cli"],
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto"
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
3
3
  import { join } from "node:path"
4
4
  import { spawnSync } from "node:child_process"
5
- import { CliError } from "./util"
5
+ import { CliError } from "../cli"
6
6
 
7
7
  /*
8
8
  * The S3 release channel the CLI downloads native tools from: the manifest scripts/release/*