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,19 +1,20 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { dirname, resolve } from "node:path"
3
3
  import { compileHeadlessBundle } from "../compile/headlessBundle"
4
- import { screenTargetFromArgs } from "../compile/screenEntry"
5
- import { findProjectRoot } from "../manifest"
4
+ import { screenTargetFrom } from "../compile/screenEntry"
5
+ import { findProjectRoot } from "../project/manifest"
6
6
  import { buildBundle } from "./compile"
7
- import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
8
- import { runDesktopRender } from "../desktopRenderer"
9
- import { evaluateDesktopRun, translateScenario } from "../desktopScript"
10
- import { CliError, c, flagStr, flagBool, isNullSink, logErr, note, warnErr, type Args } from "../util"
11
- import { loadPeer } from "../peerInstall"
7
+ import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
8
+ import { runDesktopRender } from "../hosts/desktopRenderer"
9
+ import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
10
+ import { CliError, bool, c, defineCommand, isNullSink, logErr, note, num, str, warnErr, type FlagValues } from "../cli"
11
+ import { bundleFlags, bundleOptions, outFlag } from "./shared"
12
+ import { loadPeer } from "../hosts/peerInstall"
12
13
 
13
14
  /*
14
15
  * `lecodes render` — compile the local project and render it headless. By default it emits a
15
16
  * semantic JSON description (stdout or -o/--out); `--png [file]` also rasterizes a PNG. Rendering
16
- * runs locally via the optional `lecodes-renderer` package (canvas-ui + the layout WASM, and the
17
+ * runs locally via the optional `lecodes-headless` package (canvas-ui + the layout WASM, and the
17
18
  * GPU-free creator-2d WASM for 2D scenes); PNG also needs @napi-rs/canvas. UI and 2D (creator-2d)
18
19
  * scenes are both rendered — the 2D scene composites UNDER the UI. 3D (Filament) isn't rasterized,
19
20
  * but the scene graph is recorded and serialized into the JSON output as `scene3d` (hierarchy,
@@ -29,32 +30,49 @@ import { loadPeer } from "../peerInstall"
29
30
  *
30
31
  * `--script <file>` switches to an interactive run: the file lists steps (tap / type / scroll /
31
32
  * wait / expect / screenshot / json) executed against a live session — see the scenario schema in
32
- * lecodes-renderer (headless/scenario.ts). Step outputs land under --out-dir (default: the CWD);
33
+ * lecodes-headless (headless/scenario.ts). Step outputs land under --out-dir (default: the CWD);
33
34
  * the run stops at the first failed step and dumps the failing frame next to them.
34
35
  *
36
+ * `--desktop` renders through the NATIVE desktop host instead of the headless renderer — the real
37
+ * tgfx UI + Filament 3D + creator-2d pipeline, composited exactly as desktop users see it. PNG only
38
+ * (the host has no semantic-JSON serializer), no lecodes-headless / @napi-rs/canvas needed. The
39
+ * host binary downloads on first use (see desktopRenderer.ts); Windows + Linux for now.
40
+ *
35
41
  * Works in both server-backed projects (login) and local `lecodes init` projects (no login) — see
36
42
  * compile/headlessBundle.ts. Resource assets (asset('./x.png')) resolve from the local working tree
37
43
  * by default; use --remote-assets (or an explicit --public-url) to load them from the server
38
44
  * instead, which then needs --wait-network.
39
45
  *
40
- * Snapshot timing flags:
41
- * --settle <ms> max wait for promises/timers/fetch/image decode (ends early when idle)
42
- * --wait-network let data fetches hit the real network (off by default → empty responses)
43
- * --remote-assets load asset() resources from the server URL instead of the local disk
44
- * --time <ms> render at this animation time (0 = the initial frame-0 state)
45
- * --no-after-animations keep frame 0 instead of advancing animations to their resting state
46
- * --timeout <ms> hard cap on the whole render before aborting (default 15000; a script
47
- * run defaults to 15000 + 3000 per step)
48
- * --safe-area t,r,b,l override the safe-area insets (px) the `safe-*` keywords resolve to
49
- * --device <preset> safe-area preset: none | iphone | android (default: orientation-based).
50
- * Presets also carry the edge KIND: android's status/nav bars are exact
51
- * heights (comfort-* adds its knob past them); iphone insets are
52
- * clearance zones (comfort-* floors). --safe-area alone = all clearance.
53
- * --logs forward the app's console.log/error (to stderr) for logic debugging
54
- * --clip <name|x,y,w,h> (--png) crop to a named node's layout rect or an explicit logical-px
55
- * rect — a component / region render instead of the whole viewport
46
+ * The viewport / timing flags and the --desktop flags are exported as groups because `lecodes test`
47
+ * takes the very same ones. The device presets carry the edge KIND: android's status/nav bars are
48
+ * exact heights (comfort-* adds its knob past them); iphone insets are clearance zones (comfort-*
49
+ * floors). --safe-area alone = all clearance.
56
50
  */
57
51
 
52
+ // ---- flag groups (shared with `lecodes test`) ------------------------------------------------
53
+
54
+ /** Viewport + snapshot timing. */
55
+ export const viewportFlags = {
56
+ width: num("viewport width, logical px (default 390 — a desktop-only project renders at its window, a design screen at its board frame)"),
57
+ height: num("viewport height, logical px (default 844, same fallbacks)"),
58
+ settle: num("max wait for promises / timers / fetch / image decode before the snapshot (ends early when idle)", { value: "<ms>" }),
59
+ "wait-network": bool("let data fetches hit the real network (off by default → empty responses)"),
60
+ device: str("safe-area preset: none | iphone | android (default: orientation-based)", { value: "<preset>" }),
61
+ "safe-area": str("override the safe-area insets (px) the safe-* keywords resolve to", { value: "<t,r,b,l>" }),
62
+ logs: bool("forward the app's console output (to stderr)"),
63
+ }
64
+
65
+ /** `--desktop`: the NATIVE host in place of the headless renderer. */
66
+ export const desktopRenderFlags = {
67
+ desktop: bool("render through the NATIVE desktop host instead: real Filament 3D + tgfx UI + 2D, composited. PNG only (--png). Downloads the LeCodes Desktop renderer on first use into ~/.lecodes/renderer (Windows/Linux; LECODES_DESKTOP_EXE overrides)"),
68
+ sim: bool("run a --script scenario through the WINDOWLESS native host (lecodes-server, beside the desktop host; LECODES_SERVER_EXE overrides): the real engine — Jolt, animators, navmesh — with no GPU and no pixels, so it runs on a CI box or a container and in parallel with an open desktop window. expect.log / expect.paint only; input is scene input (key/look/stick/drag/tap by { x, y } straight to the scene — no UI, so no names, hover or type); no screenshots, no --png"),
69
+ frames: num("(--desktop) frames to run before the shot — headroom for async loads", { default: 60 }),
70
+ "fixed-dt": num("(--desktop) virtual app clock, ms per frame — deterministic game time (scripted runs default to 16; plain renders to wall clock)", { value: "<ms>" }),
71
+ renderer: str("(--desktop) host + shader variant: gl | vulkan (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
72
+ }
73
+
74
+ // ---- helpers `lecodes test` shares -----------------------------------------------------------
75
+
58
76
  // Approximate device safe-area presets, portrait: insets [top, right, bottom, left] (px) + which
59
77
  // edges are exact-height system BARS. `comfort-*` ADDS its knob past a bar edge (content at the
60
78
  // inset touches the chrome) and floors (max) a clearance edge (breathing room built in).
@@ -83,8 +101,8 @@ export const resolveSafeAreaChoice = (explicit: string | undefined, device: stri
83
101
  }
84
102
 
85
103
  /** The optional renderer package — installed into ~/.lecodes/peers on first use (peerInstall.ts). */
86
- export const requireRenderer = async (): Promise<typeof import("lecodes-renderer/headless")> =>
87
- loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes render" })
104
+ export const requireRenderer = async (): Promise<typeof import("lecodes-headless/headless")> =>
105
+ loadPeer<typeof import("lecodes-headless/headless")>("lecodes-headless", "headless", { for: "lecodes render" })
88
106
 
89
107
  /** A file-writing ScenarioIO rooted at `dir` (creates parent folders as needed). */
90
108
  export const fileScenarioIO = (dir: string) => ({
@@ -113,7 +131,7 @@ type ScenarioFile = {
113
131
  export const resolveFixtures = (
114
132
  value: Record<string, unknown> | string | undefined,
115
133
  baseDir: string,
116
- ): import("lecodes-renderer/headless").Fixtures | undefined => {
134
+ ): import("lecodes-headless/headless").Fixtures | undefined => {
117
135
  if (value === undefined) return undefined
118
136
  let map: unknown = value
119
137
  if (typeof value === "string") {
@@ -127,7 +145,7 @@ export const resolveFixtures = (
127
145
  if (typeof map !== "object" || map === null || Array.isArray(map)) {
128
146
  throw new CliError(`Fixtures must be an object mapping URL patterns to { status?, json?, text? }.`)
129
147
  }
130
- return map as import("lecodes-renderer/headless").Fixtures
148
+ return map as import("lecodes-headless/headless").Fixtures
131
149
  }
132
150
 
133
151
  const readScenarioFile = (path: string): ScenarioFile => {
@@ -157,11 +175,82 @@ const parseClip = (raw: string | undefined): string | { x: number, y: number, wi
157
175
  throw new CliError(`--clip: expected a node name or "x,y,width,height" (got "${raw}").`)
158
176
  }
159
177
 
160
- /* `--desktop`: render through the NATIVE desktop host instead of the headless renderer — the real
161
- * tgfx UI + Filament 3D + creator-2d pipeline, composited exactly as desktop users see it. PNG
162
- * only (the host has no semantic-JSON serializer), no lecodes-renderer / @napi-rs/canvas needed.
163
- * The host binary downloads on first use (see desktopRenderer.ts); Windows + Linux for now. */
164
- const renderDesktop = async (args: Args) => {
178
+ /** `--png` / `--map` take an optional file: bare = the default name, absent = off. */
179
+ const optionalFile = (value: string | undefined, fallback: string): string | undefined =>
180
+ value === undefined ? undefined : value || fallback
181
+
182
+ // ---- the command -----------------------------------------------------------------------------
183
+
184
+ const renderFlags = {
185
+ state: str("([file]) the state to pass a (state) => UIScreen screen", { value: "<name>" }),
186
+ dir: str("([file]) the design folder a bare screen id resolves in (default design/)", { value: "<folder>" }),
187
+ ...outFlag,
188
+ png: str("rasterize a PNG (default screenshot.png; needs @napi-rs/canvas)", { value: "[file]", optional: true }),
189
+ map: str("draw the 3D scene's annotated top-down map (grid + XZ footprints + named markers) as <file>.png + <file>.json world-space data (default map.png; also a { \"map\": \"f.png\" } script step)", { value: "[file]", optional: true }),
190
+ "label-all": bool("(--map) label every object, not only the named markers"),
191
+ "max-px": num("(--map) longest edge of the map image, px"),
192
+ script: str("drive the app through a step list (tap/type/fill/scroll/hover/wait/expect/screenshot/json steps; games: frames/drag/hold/key/keyDown/keyUp, expect.log, screenshot clip). With --desktop the same steps, targets (a name or { x, y }) and expects run on the native host — only expect.audio and the json output are headless-only; per-step screenshots + final --png", { value: "<file>" }),
193
+ "out-dir": str("where --script step outputs land", { value: "<dir>", default: "." }),
194
+ clip: str("(--png) crop to one node's rect or a logical-px rect — a component / region render", { value: "<name|x,y,w,h>" }),
195
+ fixtures: str("canned fetch responses (URL pattern → { status?, json?, text? }) — make backend-gated screens reachable headless; merged over the scenario file's own", { value: "<file>" }),
196
+ scale: num("PNG device-pixel scale", { default: 2 }),
197
+ time: num("render at this animation time (0 = the initial frame-0 state)", { value: "<ms>" }),
198
+ "no-after-animations": bool("keep frame 0 instead of advancing animations to their resting state"),
199
+ timeout: num("hard cap on the whole render before aborting (default 15000, + 3000 per script step; --desktop 60000, + 5000 per step)", { value: "<ms>" }),
200
+ ...viewportFlags,
201
+ ...bundleFlags,
202
+ ...desktopRenderFlags,
203
+ view: str("(--desktop) inspection camera auto-fitted to the whole 3D scene: iso | top | front | side | back [--fov N]; the app's camera is untouched", { value: "<name>" }),
204
+ camera: str("(--desktop) inspection camera at a fixed eye point (world units, Y up) — goes with --look-at [--fov N]. In a --script run use { \"view\" } / { \"camera\" } steps", { value: "<x,y,z>" }),
205
+ "look-at": str("(--desktop) the point --camera looks at", { value: "<x,y,z>" }),
206
+ fov: num("(--desktop) vertical field of view for --view / --camera, degrees", { value: "<deg>" }),
207
+ }
208
+
209
+ type RenderFlags = FlagValues<typeof renderFlags>
210
+
211
+ export default defineCommand({
212
+ name: "render",
213
+ summary: "Render the local project headless (JSON / PNG), or through the desktop host",
214
+ usage: "[file]",
215
+ description: "Needs the optional lecodes-headless package (installed on first use). [file] renders ONE screen module instead of the app: a file that default-exports a UIScreen (or a (state) => UIScreen) — every `lecodes design` screen is written that way. A bare id (\"login\") resolves in design/screens/, and a design screen renders at its meta.json frame.\n\nasset() resources load from the working tree by default; --remote-assets / --public-url use the server's copies (then data needs --wait-network).",
216
+ flags: renderFlags,
217
+ examples: [
218
+ "lecodes render -o ui.json",
219
+ "lecodes render --png shots/home.png --device iphone",
220
+ "lecodes render login --state empty --png",
221
+ "lecodes render --script tests/hero.flow.json --out-dir tests/.shots --logs",
222
+ "lecodes render --desktop --png --view iso",
223
+ ],
224
+ run: ({ args, flags }) => flags.desktop || flags.sim ? renderDesktop(flags, args[0]) : renderHeadless(flags, args[0]),
225
+ })
226
+
227
+ // ---- --desktop -------------------------------------------------------------------------------
228
+
229
+ const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
230
+ const parseVec3Flag = (name: string, raw: string): string => {
231
+ const parts = raw.split(",").map((p) => p.trim())
232
+ if (parts.length !== 3 || parts.some((p) => !/^-?\d+(\.\d+)?$/.test(p))) throw new CliError(`--${name} wants "x,y,z" (got "${raw}").`)
233
+ return parts.join(",")
234
+ }
235
+
236
+ /** `--camera x,y,z --look-at x,y,z [--fov N]` → CREATOR_CAMERA; `--view name [--fov N]` → CREATOR_VIEW. */
237
+ const resolveInspectionCamera = ({ camera: cam, "look-at": look, view, fov }: Pick<RenderFlags, "camera" | "look-at" | "view" | "fov">): { camera?: string, view?: string } => {
238
+ if (fov !== undefined && !(fov > 0 && fov < 180)) throw new CliError(`--fov wants degrees in (0, 180), got "${fov}".`)
239
+ const fovSuffix = fov !== undefined ? `;${fov}` : ""
240
+ if (cam !== undefined || look !== undefined) {
241
+ if (view !== undefined) throw new CliError("Use either --camera/--look-at or --view, not both.")
242
+ if (cam === undefined || look === undefined) throw new CliError("--camera and --look-at go together: --camera x,y,z --look-at x,y,z")
243
+ return { camera: `${parseVec3Flag("camera", cam)};${parseVec3Flag("look-at", look)}${fovSuffix}` }
244
+ }
245
+ if (view !== undefined) {
246
+ if (!VIEW_NAMES.includes(view)) throw new CliError(`--view wants one of ${VIEW_NAMES.join(", ")} (got "${view}").`)
247
+ return { view: `${view}${fovSuffix}` }
248
+ }
249
+ if (fov !== undefined) throw new CliError("--fov goes with --camera/--look-at or --view.")
250
+ return {}
251
+ }
252
+
253
+ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Promise<void> => {
165
254
  // A project with a desktop block renders at its configured window (this IS the desktop host);
166
255
  // the phone-portrait literal stays only for projects that never declared one.
167
256
  let root: string
@@ -169,48 +258,51 @@ const renderDesktop = async (args: Args) => {
169
258
  const win = readAppConfigAt(root)?.desktop?.window
170
259
  // `render <file>`: one screen module rather than the app (see screenEntry.ts) — the native host
171
260
  // runs the same bundle, so a design screen renders through the real tgfx UI here.
172
- const screen = screenTargetFromArgs(root, args)
173
- const scriptFlag = flagStr(args, "script")
174
- const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null
175
- const width = Number(flagStr(args, "width")) || scenario?.width || screen?.frame?.[0] || win?.width || 390
176
- const height = Number(flagStr(args, "height")) || scenario?.height || screen?.frame?.[1] || win?.height || 844
177
- const frames = Number(flagStr(args, "frames")) || 60
178
- const pngFlag = flagStr(args, "png") ?? "screenshot.png"
179
- const logs = flagBool(args, "logs")
261
+ const screen = screenTargetFrom(root, { file, state: flags.state, entry: flags.entry, dir: flags.dir })
262
+ const scenario = flags.script !== undefined ? readScenarioFile(flags.script) : null
263
+ // --sim: the windowless host — a scripted run judged by its log; nothing to render without a script.
264
+ const sim = flags.sim
265
+ if (sim && !scenario) throw new CliError("--sim runs a --script scenario (the windowless host paints nothing to render) — add --script <file>, or use --desktop for a PNG.")
266
+ if (sim && flags.png) throw new CliError("--sim produces no PNG (the windowless host paints nothing) — drop --png, or use --desktop.")
267
+ const width = flags.width || scenario?.width || screen?.frame?.[0] || win?.width || 390
268
+ const height = flags.height || scenario?.height || screen?.frame?.[1] || win?.height || 844
269
+ const frames = flags.frames || 60
270
+ const pngFlag = flags.png || "screenshot.png"
271
+ const logs = flags.logs
180
272
  // --fixed-dt <ms>: virtual app clock (CREATOR_FIXED_DT). Scripted runs default to 16 so a held
181
273
  // key / drag / wait means the same game time on every machine; plain renders keep the wall clock
182
274
  // (their contract is "frame N", unchanged) unless asked.
183
- const fixedDtFlag = flagStr(args, "fixed-dt")
184
- const fixedDtMs = fixedDtFlag !== undefined ? Number(fixedDtFlag) : scenario ? 16 : 0
185
- if (!Number.isFinite(fixedDtMs) || fixedDtMs < 0 || fixedDtMs > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${fixedDtFlag}".`)
275
+ const fixedDtMs = flags["fixed-dt"] ?? (scenario ? 16 : 0)
276
+ if (fixedDtMs < 0 || fixedDtMs > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${fixedDtMs}".`)
186
277
 
187
278
  // Inspection camera: --camera x,y,z --look-at x,y,z [--fov N], or --view iso|top|front|side|back
188
279
  // [--fov N] (auto-fitted to the scene's bounds). App input can't be trusted while it's on, so a
189
280
  // scripted run sets it per step ({ "view": … } / { "camera": … }) instead of globally.
190
- const { camera, view } = resolveInspectionCamera(args)
281
+ const { camera, view } = resolveInspectionCamera(flags)
191
282
  if ((camera || view) && scenario) throw new CliError("--camera/--view apply to a plain render; in a --script run use a { \"view\": \"iso\" } or { \"camera\": { \"at\", \"lookAt\" } } step before the screenshot.")
192
283
 
193
- // One renderer decision: the host binary AND the staged .filamat variant (see appShared).
194
- const desktopRenderer = resolveDesktopRenderer(root, args)
284
+ // One renderer decision: the host binary AND the staged .filamat variant (see app/shared).
285
+ const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
195
286
  // Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
196
287
  const { dirs: pluginDirs } = desktopPluginLibraries(root)
197
- const { js, resources } = await buildBundle(args, undefined, {
288
+ const { js, resources } = await buildBundle(bundleOptions(flags), {
198
289
  localAssets: true, desktopRenderer,
199
290
  extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
200
291
  })
201
292
  // The host writes PNGs but never creates folders — make every output dir up front.
202
- mkdirSync(dirname(resolve(pngFlag)), { recursive: true })
293
+ if (!sim) mkdirSync(dirname(resolve(pngFlag)), { recursive: true })
203
294
 
204
295
  if (scenario) {
205
296
  // Scripted native run: translate the scenario into the host's command file, let the host drive
206
297
  // it (it self-terminates), then read its markers back into per-step results.
207
- const outDir = flagStr(args, "out-dir") ?? "."
208
- const plan = translateScenario(scenario.steps, { outDir, headroomFrames: frames })
298
+ const outDir = flags["out-dir"]
299
+ const plan = translateScenario(scenario.steps, { outDir, headroomFrames: frames, sim })
209
300
  for (const st of plan.steps) if (st.screenshot) mkdirSync(dirname(st.screenshot), { recursive: true })
210
- const timeoutMs = Number(flagStr(args, "timeout")) || (60000 + plan.steps.length * 5000)
211
- note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (native)…`)
301
+ const timeoutMs = flags.timeout || (60000 + plan.steps.length * 5000)
302
+ const label = sim ? "sim" : "native"
303
+ note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (${label})…`)
212
304
  const { output } = await runDesktopRender(js, resources, {
213
- renderer: desktopRenderer, pluginDirs,
305
+ renderer: desktopRenderer, host: sim ? "server" : "desktop", pluginDirs,
214
306
  width, height, frames: plan.frameCap, outPng: pngFlag, logs, timeoutMs, script: plan.text, fixedDtMs,
215
307
  })
216
308
  const result = evaluateDesktopRun(plan, output)
@@ -224,12 +316,12 @@ const renderDesktop = async (args: Args) => {
224
316
  const skippedCount = result.steps.reduce((n, s) => n + s.skipped.length, 0)
225
317
  if (skippedCount > 0) logErr(c.dim(` ${skippedCount} semantic check${skippedCount === 1 ? "" : "s"} skipped — the native host has no UI tree; run the same scenario headless to verify them.`))
226
318
  if (!result.ended) warnErr("The host stopped before the script finished (frame cap / crash) — later steps are reported as incomplete.")
227
- logErr(result.ok ? c.green(`✓ ${result.steps.length}/${result.steps.length} steps passed (native)`) : c.red(`✗ ${result.steps.filter((s) => !s.ok).length} of ${result.steps.length} steps failed (native)`))
228
- note(`Wrote ${pngFlag} (${width}x${height}, native render, final frame)`)
319
+ logErr(result.ok ? c.green(`✓ ${result.steps.length}/${result.steps.length} steps passed (${label})`) : c.red(`✗ ${result.steps.filter((s) => !s.ok).length} of ${result.steps.length} steps failed (${label})`))
320
+ if (!sim) note(`Wrote ${pngFlag} (${width}x${height}, native render, final frame)`)
229
321
  process.exit(result.ok ? 0 : 1)
230
322
  }
231
323
 
232
- const timeoutMs = Number(flagStr(args, "timeout")) || 60000
324
+ const timeoutMs = flags.timeout || 60000
233
325
  note(screen ? `Rendering ${screen.label} (native)…` : "Rendering (native)…")
234
326
  await runDesktopRender(js, resources, {
235
327
  renderer: desktopRenderer, pluginDirs,
@@ -238,72 +330,45 @@ const renderDesktop = async (args: Args) => {
238
330
  note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
239
331
  }
240
332
 
241
- const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
242
- const parseVec3Flag = (name: string, raw: string): string => {
243
- const parts = raw.split(",").map((p) => p.trim())
244
- if (parts.length !== 3 || parts.some((p) => !/^-?\d+(\.\d+)?$/.test(p))) throw new CliError(`--${name} wants "x,y,z" (got "${raw}").`)
245
- return parts.join(",")
246
- }
247
- /** `--camera x,y,z --look-at x,y,z [--fov N]` → CREATOR_CAMERA; `--view name [--fov N]` → CREATOR_VIEW. */
248
- const resolveInspectionCamera = (args: Args): { camera?: string, view?: string } => {
249
- const cam = flagStr(args, "camera"), look = flagStr(args, "look-at"), view = flagStr(args, "view"), fov = flagStr(args, "fov")
250
- if (fov !== undefined && !(Number(fov) > 0 && Number(fov) < 180)) throw new CliError(`--fov wants degrees in (0, 180), got "${fov}".`)
251
- const fovSuffix = fov !== undefined ? `;${Number(fov)}` : ""
252
- if (cam !== undefined || look !== undefined) {
253
- if (view !== undefined) throw new CliError("Use either --camera/--look-at or --view, not both.")
254
- if (cam === undefined || look === undefined) throw new CliError("--camera and --look-at go together: --camera x,y,z --look-at x,y,z")
255
- return { camera: `${parseVec3Flag("camera", cam)};${parseVec3Flag("look-at", look)}${fovSuffix}` }
256
- }
257
- if (view !== undefined) {
258
- if (!VIEW_NAMES.includes(view)) throw new CliError(`--view wants one of ${VIEW_NAMES.join(", ")} (got "${view}").`)
259
- return { view: `${view}${fovSuffix}` }
260
- }
261
- if (fov !== undefined) throw new CliError("--fov goes with --camera/--look-at or --view.")
262
- return {}
263
- }
264
-
265
- export const render = async (args: Args) => {
266
- if (flagBool(args, "desktop")) return renderDesktop(args)
333
+ // ---- headless --------------------------------------------------------------------------------
267
334
 
268
- const scriptFlag = flagStr(args, "script")
269
- const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null
335
+ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Promise<void> => {
336
+ const scenario = flags.script !== undefined ? readScenarioFile(flags.script) : null
270
337
 
271
338
  // `render <file>`: render that one screen module instead of the app's own entrypoint.
272
339
  let projectRoot: string
273
340
  try { projectRoot = findProjectRoot(process.cwd()) } catch { projectRoot = process.cwd() }
274
- const screen = screenTargetFromArgs(projectRoot, args)
341
+ const screen = screenTargetFrom(projectRoot, { file, state: flags.state, entry: flags.entry, dir: flags.dir })
275
342
 
276
343
  // Hard timeout so a pathological scene (stuck fetch, runaway loop) can't hang the CLI. Scripts get
277
344
  // a per-step allowance. Unref'd so it never itself keeps the process alive once we're done.
278
345
  const stepCount = scenario && Array.isArray(scenario.steps) ? scenario.steps.length : 0
279
- const timeoutMs = Number(flagStr(args, "timeout")) || (15000 + stepCount * 3000)
346
+ const timeoutMs = flags.timeout || (15000 + stepCount * 3000)
280
347
  const killer = setTimeout(() => {
281
348
  warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`)
282
349
  process.exit(1)
283
350
  }, timeoutMs)
284
351
  killer.unref?.()
285
352
 
286
- const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args, { screen })
353
+ const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle({ screen, entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
287
354
  const renderer = await requireRenderer()
288
355
 
289
356
  // Flags override the scenario file's envelope; both fall back to the project default (a
290
357
  // desktop-only app.json renders desktop-sized), then the phone-portrait literals.
291
358
  // A design screen carries its own board frame (meta.json), which beats the app-wide default.
292
359
  const projectViewport = desktopDefaultViewport(root)
293
- const width = Number(flagStr(args, "width")) || scenario?.width || screen?.frame?.[0] || projectViewport?.width || 390
294
- const height = Number(flagStr(args, "height")) || scenario?.height || screen?.frame?.[1] || projectViewport?.height || 844
295
-
296
- // Shared snapshot-timing options (see the header comment). `--logs` forwards the app's console to
297
- // stderr (kept off stdout so it never corrupts the JSON/PNG result there).
298
- const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined)
299
- const timeStr = flagStr(args, "time")
300
- const onConsole = flagBool(args, "logs")
360
+ const width = flags.width || scenario?.width || screen?.frame?.[0] || projectViewport?.width || 390
361
+ const height = flags.height || scenario?.height || screen?.frame?.[1] || projectViewport?.height || 844
362
+
363
+ // Shared snapshot-timing options. `--logs` forwards the app's console to stderr (kept off stdout
364
+ // so it never corrupts the JSON/PNG result there).
365
+ const onConsole = flags.logs
301
366
  ? (level: string, text: string) => logErr(`${c.dim(`[${level}]`)} ${text}`)
302
367
  : undefined
303
368
  // Fixtures: the scenario file's (inline map or a path), with a `--fixtures <file>` flag merged
304
369
  // over it (flag patterns win on collision). Validated here so a typo is a clean CLI error.
305
- const envelopeFixtures = resolveFixtures(scenario?.fixtures, scriptFlag !== undefined ? dirname(resolve(scriptFlag)) : ".")
306
- const flagFixtures = resolveFixtures(flagStr(args, "fixtures"), ".")
370
+ const envelopeFixtures = resolveFixtures(scenario?.fixtures, flags.script !== undefined ? dirname(resolve(flags.script)) : ".")
371
+ const flagFixtures = resolveFixtures(flags.fixtures, ".")
307
372
  const fixtures = envelopeFixtures || flagFixtures ? { ...envelopeFixtures, ...flagFixtures } : undefined
308
373
  if (fixtures) {
309
374
  try {
@@ -314,13 +379,13 @@ export const render = async (args: Args) => {
314
379
  }
315
380
 
316
381
  const timing = {
317
- settleMs: settleStr !== undefined ? Number(settleStr) : undefined,
318
- waitNetwork: flagBool(args, "wait-network"),
382
+ settleMs: flags.settle ?? scenario?.settle,
383
+ waitNetwork: flags["wait-network"],
319
384
  localAssets: useLocalAssets,
320
385
  fixtures,
321
- timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
322
- afterAnimations: !flagBool(args, "no-after-animations"),
323
- ...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? scenario?.safeArea, flagStr(args, "device") ?? scenario?.device),
386
+ timeMs: flags.time,
387
+ afterAnimations: !flags["no-after-animations"],
388
+ ...resolveSafeAreaChoice(flags["safe-area"] ?? scenario?.safeArea, flags.device ?? scenario?.device),
324
389
  onConsole,
325
390
  }
326
391
 
@@ -331,7 +396,7 @@ export const render = async (args: Args) => {
331
396
  // --script: drive the compiled app through the scenario steps against a live session.
332
397
  if (scenario) {
333
398
  const steps = renderer.parseScenarioSteps(scenario.steps)
334
- const outDir = flagStr(args, "out-dir") ?? "."
399
+ const outDir = flags["out-dir"]
335
400
  // Screenshots require pixels; otherwise rasterize opportunistically (napi installed → failure
336
401
  // PNGs and real Skia text metrics, absent → deterministic HeadlessHost metrics).
337
402
  const needsRaster = steps.some((s) => s.screenshot !== undefined)
@@ -356,12 +421,12 @@ export const render = async (args: Args) => {
356
421
  // --map [file]: draw the 3D scene's annotated top-down map (grid + XZ footprints + named
357
422
  // markers) instead of a screenshot — the level-design view. Writes <file>.png + <file>.json
358
423
  // (the same data, world-space, queryable). Default map.png. `--label-all` labels every object.
359
- const mapFlag = flagStr(args, "map") ?? (flagBool(args, "map") ? "map.png" : undefined)
424
+ const mapFlag = optionalFile(flags.map, "map.png")
360
425
  if (mapFlag !== undefined) {
361
426
  note(`Rendering map…`)
362
427
  const title = root.split(/[\\/]/).pop()
363
428
  const { png, data, warnings: mapWarnings } = await renderer.renderToMap(js, {
364
- ...timing, maxPx: Number(flagStr(args, "max-px")) || undefined, title, labelAll: flagBool(args, "label-all"),
429
+ ...timing, maxPx: flags["max-px"] || undefined, title, labelAll: flags["label-all"],
365
430
  })
366
431
  for (const w of [ ...warnings, ...mapWarnings ]) warnErr(w)
367
432
  const pngPath = mapFlag.endsWith(".png") ? mapFlag : mapFlag + ".png"
@@ -374,12 +439,12 @@ export const render = async (args: Args) => {
374
439
  }
375
440
 
376
441
  // --png [file]: rasterize a PNG (binary → always to a file, default screenshot.png).
377
- const pngFlag = flagStr(args, "png") ?? (flagBool(args, "png") ? "screenshot.png" : undefined)
442
+ const pngFlag = optionalFile(flags.png, "screenshot.png")
378
443
  if (pngFlag !== undefined) {
379
444
  note(screen ? `Rendering ${screen.label} (PNG)…` : "Rendering PNG…")
380
- const clip = parseClip(flagStr(args, "clip"))
445
+ const clip = parseClip(flags.clip)
381
446
  const { png, width: outW, height: outH, warnings: pngWarnings } = await renderer.renderToPng(js, {
382
- width, height, scale: Number(flagStr(args, "scale")) || 2, ...timing, clip,
447
+ width, height, scale: flags.scale || 2, ...timing, clip,
383
448
  })
384
449
  for (const w of [ ...warnings, ...pngWarnings ]) warnErr(w)
385
450
  if (isNullSink(pngFlag)) {
@@ -397,7 +462,7 @@ export const render = async (args: Args) => {
397
462
 
398
463
  for (const w of [ ...warnings, ...result.warnings ]) warnErr(w)
399
464
 
400
- const out = flagStr(args, "o", "out")
465
+ const out = flags.out
401
466
  if (out && isNullSink(out)) {
402
467
  note(`Discarded output (${out})`)
403
468
  return done((cb) => cb())