lecodes-cli 0.19.0 → 0.19.2

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 (254) hide show
  1. package/dist/index.js +730 -235
  2. package/package.json +14 -13
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk-types.json +1 -1
  5. package/src/api.ts +302 -0
  6. package/src/browserAuth.ts +87 -0
  7. package/src/cmgenTool.ts +104 -0
  8. package/src/commands/app.ts +891 -0
  9. package/src/commands/appAndroid.ts +603 -0
  10. package/src/commands/appDesktop.ts +326 -0
  11. package/src/commands/appDesktopMac.ts +470 -0
  12. package/src/commands/appIcon.ts +187 -0
  13. package/src/commands/appShared.ts +448 -0
  14. package/src/commands/appTemplates.ts +883 -0
  15. package/src/commands/appTemplatesAndroid.ts +599 -0
  16. package/src/commands/appTemplatesGradlew.ts +9 -0
  17. package/src/commands/assets.ts +28 -0
  18. package/src/commands/clone.ts +60 -0
  19. package/src/commands/compile.ts +253 -0
  20. package/src/commands/create.ts +59 -0
  21. package/src/commands/design.ts +863 -0
  22. package/src/commands/designTemplates.ts +11 -0
  23. package/src/commands/desktop.ts +122 -0
  24. package/src/commands/dev.ts +214 -0
  25. package/src/commands/diff.ts +59 -0
  26. package/src/commands/init.ts +191 -0
  27. package/src/commands/install.ts +198 -0
  28. package/src/commands/lightmap.ts +290 -0
  29. package/src/commands/link.ts +147 -0
  30. package/src/commands/login.ts +24 -0
  31. package/src/commands/navmesh.ts +225 -0
  32. package/src/commands/pn.ts +305 -0
  33. package/src/commands/projectTemplates.ts +129 -0
  34. package/src/commands/pull.ts +109 -0
  35. package/src/commands/push.ts +173 -0
  36. package/src/commands/render.ts +414 -0
  37. package/src/commands/scene.ts +148 -0
  38. package/src/commands/shaders.ts +157 -0
  39. package/src/commands/status.ts +29 -0
  40. package/src/commands/test.ts +370 -0
  41. package/src/commands/thumbs.ts +178 -0
  42. package/src/commands/types.ts +87 -0
  43. package/src/commands/update.ts +190 -0
  44. package/src/compile/assetIcons.ts +214 -0
  45. package/src/compile/collect.ts +46 -0
  46. package/src/compile/collectLocal.ts +37 -0
  47. package/src/compile/designCompile.ts +104 -0
  48. package/src/compile/fonts.ts +156 -0
  49. package/src/compile/headlessBundle.ts +129 -0
  50. package/src/compile/nativeStack.ts +21 -0
  51. package/src/compile/projectCompile.ts +109 -0
  52. package/src/compile/sceneCompile.ts +113 -0
  53. package/src/compile/screenEntry.ts +127 -0
  54. package/src/compile/shaders.ts +245 -0
  55. package/src/config.ts +42 -0
  56. package/src/designMeta.ts +35 -0
  57. package/src/desktopRenderer.ts +532 -0
  58. package/src/desktopScript.ts +276 -0
  59. package/src/dev/clientTemplates.ts +160 -0
  60. package/src/dev/devServer.ts +292 -0
  61. package/src/dev/wsServer.ts +144 -0
  62. package/src/distRoot.ts +20 -0
  63. package/src/ignore.ts +163 -0
  64. package/src/index.ts +491 -0
  65. package/src/lecodes-3d-editor.d.ts +41 -0
  66. package/src/lecodes-assets.d.ts +7 -0
  67. package/src/lecodes-design.d.ts +191 -0
  68. package/src/lecodes-renderer.d.ts +131 -0
  69. package/src/localFiles.ts +144 -0
  70. package/src/manifest.ts +46 -0
  71. package/src/matcTool.ts +137 -0
  72. package/src/peers.ts +40 -0
  73. package/src/project.ts +61 -0
  74. package/src/projectEnv.ts +94 -0
  75. package/src/qrcode-terminal.d.ts +9 -0
  76. package/src/releases.ts +125 -0
  77. package/src/serverDiff.ts +78 -0
  78. package/src/textDiff.ts +103 -0
  79. package/src/types.ts +0 -0
  80. package/src/util.ts +146 -0
  81. package/runtime/sdk/animate/animate.ts +0 -238
  82. package/runtime/sdk/animate/bezier.ts +0 -138
  83. package/runtime/sdk/animate/easings.ts +0 -126
  84. package/runtime/sdk/canvas/Canvas.ts +0 -305
  85. package/runtime/sdk/compile/aspectMacro.ts +0 -86
  86. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  87. package/runtime/sdk/compile/assetMacro.ts +0 -146
  88. package/runtime/sdk/compile/assetName.ts +0 -50
  89. package/runtime/sdk/compile/bundler.ts +0 -287
  90. package/runtime/sdk/compile/compileProject.ts +0 -129
  91. package/runtime/sdk/compile/detectEntry.ts +0 -128
  92. package/runtime/sdk/compile/fontMacro.ts +0 -459
  93. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  94. package/runtime/sdk/compile/header.ts +0 -67
  95. package/runtime/sdk/compile/index.ts +0 -100
  96. package/runtime/sdk/compile/libraryImports.ts +0 -52
  97. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  98. package/runtime/sdk/compile/sceneEditor.ts +0 -88
  99. package/runtime/sdk/compile/serverSplit.ts +0 -233
  100. package/runtime/sdk/compile/serverTypes.ts +0 -227
  101. package/runtime/sdk/compile/sfnt.ts +0 -98
  102. package/runtime/sdk/compile/shaderSchema.ts +0 -202
  103. package/runtime/sdk/compile/shaderTargets.ts +0 -81
  104. package/runtime/sdk/compile/sourcemap.ts +0 -25
  105. package/runtime/sdk/core/Aspect.ts +0 -568
  106. package/runtime/sdk/core/InspectorUI.ts +0 -212
  107. package/runtime/sdk/core/StateMachine.ts +0 -308
  108. package/runtime/sdk/core/color.ts +0 -66
  109. package/runtime/sdk/core/compWrite.ts +0 -42
  110. package/runtime/sdk/core/events.ts +0 -38
  111. package/runtime/sdk/core/fields.ts +0 -120
  112. package/runtime/sdk/core/registry.ts +0 -23
  113. package/runtime/sdk/core/signals.ts +0 -277
  114. package/runtime/sdk/core/time.ts +0 -84
  115. package/runtime/sdk/g2/Camera2D.ts +0 -40
  116. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  117. package/runtime/sdk/g2/Node2D.ts +0 -267
  118. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  119. package/runtime/sdk/g2/Physics2D.ts +0 -346
  120. package/runtime/sdk/g2/Scene2D.ts +0 -209
  121. package/runtime/sdk/g2/Shape2D.ts +0 -259
  122. package/runtime/sdk/g2/Sprite.ts +0 -89
  123. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  124. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  125. package/runtime/sdk/g2/Texture2D.ts +0 -47
  126. package/runtime/sdk/g2/Tilemap.ts +0 -41
  127. package/runtime/sdk/g2/Tileset.ts +0 -71
  128. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  129. package/runtime/sdk/g2/autotile.ts +0 -433
  130. package/runtime/sdk/g2/cells.ts +0 -91
  131. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  132. package/runtime/sdk/g2/groups2d.ts +0 -106
  133. package/runtime/sdk/g2/loop.ts +0 -50
  134. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  135. package/runtime/sdk/g2/touch.ts +0 -83
  136. package/runtime/sdk/gl/Camera.ts +0 -160
  137. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  138. package/runtime/sdk/gl/CharacterController.ts +0 -238
  139. package/runtime/sdk/gl/Gearbox.ts +0 -212
  140. package/runtime/sdk/gl/Geometry.ts +0 -279
  141. package/runtime/sdk/gl/IK.ts +0 -193
  142. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  143. package/runtime/sdk/gl/Light.ts +0 -107
  144. package/runtime/sdk/gl/Lightmap.ts +0 -256
  145. package/runtime/sdk/gl/Material.ts +0 -260
  146. package/runtime/sdk/gl/Mesh.ts +0 -83
  147. package/runtime/sdk/gl/Model.ts +0 -124
  148. package/runtime/sdk/gl/NavAgent.ts +0 -337
  149. package/runtime/sdk/gl/NavMesh.ts +0 -397
  150. package/runtime/sdk/gl/Node.ts +0 -350
  151. package/runtime/sdk/gl/Noise.ts +0 -30
  152. package/runtime/sdk/gl/Particles.ts +0 -676
  153. package/runtime/sdk/gl/Physics.ts +0 -222
  154. package/runtime/sdk/gl/Plane.ts +0 -53
  155. package/runtime/sdk/gl/Ragdoll.ts +0 -270
  156. package/runtime/sdk/gl/Ray.ts +0 -16
  157. package/runtime/sdk/gl/Scene.ts +0 -486
  158. package/runtime/sdk/gl/Shape.ts +0 -409
  159. package/runtime/sdk/gl/Terrain.ts +0 -1092
  160. package/runtime/sdk/gl/Texture.ts +0 -63
  161. package/runtime/sdk/gl/Trigger.ts +0 -45
  162. package/runtime/sdk/gl/Vehicle.ts +0 -473
  163. package/runtime/sdk/gl/Wheel.ts +0 -240
  164. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  165. package/runtime/sdk/gl/animation/Animator.ts +0 -120
  166. package/runtime/sdk/gl/animation/Layer.ts +0 -31
  167. package/runtime/sdk/gl/animation/Loop.ts +0 -30
  168. package/runtime/sdk/gl/animation/Playback.ts +0 -44
  169. package/runtime/sdk/gl/animation/core.ts +0 -336
  170. package/runtime/sdk/gl/controls.ts +0 -95
  171. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  172. package/runtime/sdk/gl/scenarios.ts +0 -291
  173. package/runtime/sdk/gl/state.ts +0 -6
  174. package/runtime/sdk/gl/terrainMesh.ts +0 -219
  175. package/runtime/sdk/gl/touch.ts +0 -68
  176. package/runtime/sdk/inject.ts +0 -197
  177. package/runtime/sdk/math/Mathf.ts +0 -118
  178. package/runtime/sdk/math/mat4.ts +0 -278
  179. package/runtime/sdk/math/quat.ts +0 -232
  180. package/runtime/sdk/math/vec.ts +0 -255
  181. package/runtime/sdk/net/codec.ts +0 -119
  182. package/runtime/sdk/net/core.ts +0 -384
  183. package/runtime/sdk/net/index.ts +0 -181
  184. package/runtime/sdk/net/replication.ts +0 -622
  185. package/runtime/sdk/plugins/camera.ts +0 -81
  186. package/runtime/sdk/plugins/geolocation.ts +0 -123
  187. package/runtime/sdk/plugins/oauth.ts +0 -61
  188. package/runtime/sdk/plugins/permission.ts +0 -7
  189. package/runtime/sdk/plugins/push.ts +0 -132
  190. package/runtime/sdk/plugins/qr.ts +0 -73
  191. package/runtime/sdk/plugins/service.ts +0 -47
  192. package/runtime/sdk/runtime/app.ts +0 -101
  193. package/runtime/sdk/runtime/appEvents.ts +0 -54
  194. package/runtime/sdk/runtime/channel.ts +0 -61
  195. package/runtime/sdk/runtime/clipboard.ts +0 -20
  196. package/runtime/sdk/runtime/datetime.ts +0 -329
  197. package/runtime/sdk/runtime/device.ts +0 -293
  198. package/runtime/sdk/runtime/fetch.ts +0 -77
  199. package/runtime/sdk/runtime/files.ts +0 -23
  200. package/runtime/sdk/runtime/input.ts +0 -175
  201. package/runtime/sdk/runtime/media.ts +0 -111
  202. package/runtime/sdk/runtime/misc.ts +0 -16
  203. package/runtime/sdk/runtime/net.ts +0 -36
  204. package/runtime/sdk/runtime/rpc.ts +0 -218
  205. package/runtime/sdk/runtime/service.ts +0 -83
  206. package/runtime/sdk/runtime/share.ts +0 -9
  207. package/runtime/sdk/runtime/storage.ts +0 -13
  208. package/runtime/sdk/runtime/touch.ts +0 -76
  209. package/runtime/sdk/scene/defineScene.ts +0 -1353
  210. package/runtime/sdk/scene/editorPlugins.ts +0 -110
  211. package/runtime/sdk/scene/gizmos.ts +0 -148
  212. package/runtime/sdk/scene/grammar.ts +0 -120
  213. package/runtime/sdk/scene/material.ts +0 -188
  214. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  215. package/runtime/sdk/server/auth/global.ts +0 -80
  216. package/runtime/sdk/server/auth/host.ts +0 -318
  217. package/runtime/sdk/server/auth/models.ts +0 -83
  218. package/runtime/sdk/server/auth/types.ts +0 -50
  219. package/runtime/sdk/server/channel.ts +0 -56
  220. package/runtime/sdk/server/context.ts +0 -36
  221. package/runtime/sdk/server/db/defineDb.ts +0 -237
  222. package/runtime/sdk/server/db/fields.ts +0 -132
  223. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  224. package/runtime/sdk/server/db/index.ts +0 -7
  225. package/runtime/sdk/server/db/marci/query.ts +0 -412
  226. package/runtime/sdk/server/db/types.ts +0 -202
  227. package/runtime/sdk/server/errors.ts +0 -12
  228. package/runtime/sdk/server/host.ts +0 -74
  229. package/runtime/sdk/server/inject.ts +0 -13
  230. package/runtime/sdk/server/runtime.ts +0 -133
  231. package/runtime/sdk/server/validate.ts +0 -87
  232. package/runtime/sdk/ui/NativeView.ts +0 -142
  233. package/runtime/sdk/ui/UI.ts +0 -39
  234. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  235. package/runtime/sdk/ui/UIButton.ts +0 -101
  236. package/runtime/sdk/ui/UIContainer.ts +0 -60
  237. package/runtime/sdk/ui/UIImage.ts +0 -83
  238. package/runtime/sdk/ui/UIInput.ts +0 -185
  239. package/runtime/sdk/ui/UIModal.ts +0 -139
  240. package/runtime/sdk/ui/UINode.ts +0 -830
  241. package/runtime/sdk/ui/UIPager.ts +0 -362
  242. package/runtime/sdk/ui/UIPopover.ts +0 -100
  243. package/runtime/sdk/ui/UIScreen.ts +0 -123
  244. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  245. package/runtime/sdk/ui/UISpacer.ts +0 -14
  246. package/runtime/sdk/ui/UITabs.ts +0 -236
  247. package/runtime/sdk/ui/UIText.ts +0 -51
  248. package/runtime/sdk/ui/UIVideo.ts +0 -88
  249. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  250. package/runtime/sdk/ui/UIWidget.ts +0 -127
  251. package/runtime/sdk/ui/fonts.ts +0 -13
  252. package/runtime/sdk/ui/presentable.ts +0 -117
  253. package/runtime/sdk/ui/router.ts +0 -132
  254. package/runtime/sdk/ui/theme.ts +0 -84
@@ -0,0 +1,157 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { findProjectRoot, readManifest, type Manifest } from "../manifest"
3
+ import { collectLocalEntries } from "../compile/collectLocal"
4
+ import {
5
+ allShaderPlatforms, applyLocalShaders, matcIdentity, shaderCompiledPlatforms, shaderIsCompiled,
6
+ shadersCacheDir,
7
+ } from "../compile/shaders"
8
+ import { fetchMatcManifest, installedMatcVersions, resolveMatcExe, resolveMatcExeCached, updateMatc } from "../matcTool"
9
+ import { semverNewer } from "../releases"
10
+ import { CliError, c, flagStr, log, note, warnErr, type Args } from "../util"
11
+ import { parseShaderSchema, type AnyShaderPlatform } from "lecodes-sdk/compile"
12
+
13
+ /*
14
+ * `lecodes shaders` — the project's custom shaders (.mat) and their LOCAL compile
15
+ * (compile/shaders.ts — the same pass `lecodes dev` runs on the fly):
16
+ * (no args) list the project's shaders + their local compile state, and the matc tool status
17
+ * compile compile every shader in the project into .lecodes/shaders (downloads matc on
18
+ * first use). `--platform a,b` compiles only those targets.
19
+ * update download the newest published matc (run after an engine upgrade)
20
+ * matc (Filament's material compiler) caches in ~/.lecodes/matc/; LECODES_MATC overrides it.
21
+ *
22
+ * A shader compiles to one artifact per target: `opengl` + `metal` (mobile profile, Android/Apple),
23
+ * `desktop` (desktop profile, Windows desktop GL), `desktop-vulkan` (desktop profile, SPIR-V — the
24
+ * Vulkan desktop host), and the `webgl-lite` JSON. Compiling all of them is the default
25
+ * because one bundle serves every host and the runtime picks by `_creator.backend`; `--platform`
26
+ * exists for when you know the target — a Windows-desktop-only project needs one of the five, and
27
+ * skipping the other matc runs is most of the shader step.
28
+ */
29
+
30
+ /** The project's shader entries from the working tree (works with or without a server manifest). */
31
+ const collectShaders = () => {
32
+ let root: string
33
+ let manifest: Manifest | null
34
+ try {
35
+ root = findProjectRoot(process.cwd())
36
+ manifest = readManifest(root)
37
+ } catch {
38
+ root = process.cwd()
39
+ manifest = null
40
+ }
41
+ const { entries } = collectLocalEntries(root, manifest, "", { localShaders: true })
42
+ return { root, entries, shaders: entries.filter((e) => e.type === "shader" && e.absPath) }
43
+ }
44
+
45
+ const status = async () => {
46
+ const { root, shaders } = collectShaders()
47
+
48
+ const installed = installedMatcVersions()
49
+ const matcExe = resolveMatcExeCached()
50
+ log(`matc ${matcExe
51
+ ? (process.env.LECODES_MATC ? `LECODES_MATC=${process.env.LECODES_MATC}` : `${installed[0]} ${c.dim("(~/.lecodes/matc)")}`)
52
+ : c.dim("not downloaded — fetched by `lecodes shaders compile` / `lecodes dev`")}`)
53
+
54
+ if (shaders.length === 0) {
55
+ log(c.dim("No .mat shaders in this project."))
56
+ } else {
57
+ const matcId = matcExe ? matcIdentity(matcExe) : null
58
+ log(`shaders ${c.dim(`(compiled = up-to-date artifacts in ${shadersCacheDir(root)})`)}`)
59
+ const width = Math.max(...shaders.map((s) => s.path.length))
60
+ for (const shader of shaders) {
61
+ let state: string
62
+ if (matcId === null) state = c.dim("unknown (matc not downloaded)")
63
+ else {
64
+ const text = readFileSync(shader.absPath!, "utf8")
65
+ // Distinguish a deliberate subset (`--platform`, or a desktop build's single target) from a
66
+ // stale cache — otherwise a narrower compile reads as permanently broken and invites a full
67
+ // recompile that throws the saving away.
68
+ if (shaderIsCompiled(root, text, matcId)) state = "compiled"
69
+ else {
70
+ const present = shaderCompiledPlatforms(root, text, matcId)
71
+ state = present.length === 0
72
+ ? c.yellow("not compiled — run `lecodes shaders compile`")
73
+ : c.yellow(`partial (${present.join(", ")}) — \`lecodes shaders compile\` for the rest`)
74
+ }
75
+ }
76
+ log(` ${shader.path.padEnd(width)} ${state}`)
77
+ // the parameter table the scene editor's material card renders (sdk/compile shaderSchema)
78
+ const schema = parseShaderSchema(readFileSync(shader.absPath!, "utf8"))
79
+ for (const p of schema.params) {
80
+ const range = p.min !== undefined ? ` [${p.min}..${p.max}]` : ""
81
+ const def = p.default !== null && p.default !== undefined ? ` = ${JSON.stringify(p.default)}` : ""
82
+ log(` ${c.dim(p.type.padEnd(10))} ${p.name}${c.dim(` ${p.editor}${range}${def}`)}${p.tooltip ? c.dim(` — ${p.tooltip}`) : ""}`)
83
+ }
84
+ for (const e of schema.errors) log(` ${c.yellow(e)}`)
85
+ }
86
+ }
87
+
88
+ try {
89
+ const { latest } = await fetchMatcManifest()
90
+ const newest = installed[0]
91
+ const hint = !newest ? ""
92
+ : semverNewer(newest, latest) > 0 ? ` ${c.yellow("(newer — run `lecodes shaders update`)")}`
93
+ : latest === newest ? ` ${c.dim("(up to date)")}` : ""
94
+ log(`published ${latest}${hint}`)
95
+ } catch (e) {
96
+ log(`published ${c.dim(e instanceof CliError ? e.message : "unknown (couldn't reach the release manifest)")}`)
97
+ }
98
+ }
99
+
100
+ /** Parse `--platform a,b` into a validated target list. A typo must fail loudly: silently compiling
101
+ * nothing would look like success and leave the cache short of what the caller asked for. */
102
+ const parsePlatforms = (args: Args): AnyShaderPlatform[] | undefined => {
103
+ const raw = flagStr(args, "platform", "platforms")
104
+ if (raw === undefined) return undefined
105
+ const known = allShaderPlatforms()
106
+ const asked = raw.split(",").map((p) => p.trim()).filter(Boolean)
107
+ if (asked.length === 0) throw new CliError(`--platform needs at least one target. Known: ${known.join(", ")}`)
108
+ const bad = asked.filter((p) => !known.includes(p as AnyShaderPlatform))
109
+ if (bad.length > 0) {
110
+ throw new CliError(`Unknown shader platform${bad.length > 1 ? "s" : ""}: ${bad.join(", ")}\n Known: ${known.join(", ")}`)
111
+ }
112
+ return asked as AnyShaderPlatform[]
113
+ }
114
+
115
+ /** Compile every project shader into the local cache — what `lecodes dev` does lazily, run
116
+ * explicitly (e.g. to pre-warm before going offline, or to see all compile errors at once).
117
+ * `--platform` narrows the targets; compiling is incremental per artifact, so a narrow run now and
118
+ * a full one later add up rather than redoing each other's work. */
119
+ const compile = async (args: Args) => {
120
+ const platforms = parsePlatforms(args)
121
+ const { root, entries, shaders } = collectShaders()
122
+ if (shaders.length === 0) {
123
+ note("No .mat shaders in this project.")
124
+ return
125
+ }
126
+ // Resolve matc up front so an explicit compile FAILS on an unavailable tool (the dev server's
127
+ // lazy pass degrades to a warning instead — see applyLocalShaders).
128
+ const matcExe = await resolveMatcExe()
129
+ const matcId = matcIdentity(matcExe)
130
+ const wasCompiled = new Map(shaders.map((s) => [s.path, shaderIsCompiled(root, readFileSync(s.absPath!, "utf8"), matcId)]))
131
+
132
+ const errors: string[] = []
133
+ for (const shader of shaders) {
134
+ try {
135
+ // One entry at a time so a broken shader doesn't hide the state of the others.
136
+ const { warnings } = await applyLocalShaders([shader], { root, local: true, platforms })
137
+ for (const w of warnings) warnErr(w)
138
+ log(` ${shader.path} ${wasCompiled.get(shader.path) ? c.dim("compiled (cached)") : "compiled"}`)
139
+ } catch (e) {
140
+ const message = e instanceof CliError ? e.message : String(e)
141
+ log(` ${shader.path} ${c.red("FAILED")}`)
142
+ errors.push(message)
143
+ }
144
+ }
145
+ for (const e of errors) log(`\n${c.red("✗")} ${e}`)
146
+ if (errors.length > 0) throw new CliError(`${errors.length} of ${shaders.length} shader${shaders.length === 1 ? "" : "s"} failed to compile.`)
147
+ const scope = platforms ? ` [${platforms.join(", ")}]` : ""
148
+ note(`${shaders.length} shader${shaders.length === 1 ? "" : "s"} compiled${scope} → ${shadersCacheDir(root)}`)
149
+ }
150
+
151
+ export const shaders = async (args: Args) => {
152
+ const sub = args._[0]
153
+ if (sub === undefined) return status()
154
+ if (sub === "compile") return compile(args)
155
+ if (sub === "update") return updateMatc()
156
+ throw new CliError(`Unknown subcommand "shaders ${sub}". Use \`lecodes shaders\` (status), \`lecodes shaders compile [--platform a,b]\` or \`lecodes shaders update\`.`)
157
+ }
@@ -0,0 +1,29 @@
1
+ import { findProjectRoot, readManifest } from "../manifest"
2
+ import { diffLocal, hasChanges, scanProject } from "../localFiles"
3
+ import { c, info, log, type Args } from "../util"
4
+
5
+ /* `lecodes status` — show local changes vs the last clone/pull/push. */
6
+ export const status = async (_args: Args) => {
7
+ const root = findProjectRoot(process.cwd())
8
+ const manifest = readManifest(root)
9
+ const scan = scanProject(root, manifest)
10
+ const diff = diffLocal(manifest, scan)
11
+
12
+ info(`Project ${c.bold(manifest.name)} (${manifest.uuid})`)
13
+ const ignoredNote = scan.ignored.length ? c.dim(` (${scan.ignored.length} ignored)`) : ""
14
+ if (!hasChanges(diff)) {
15
+ info(c.dim("Working tree clean — nothing to push.") + ignoredNote)
16
+ return
17
+ }
18
+
19
+ for (const f of diff.added) {
20
+ if (!diff.moves.has(f.path)) log(c.green(` added ${f.path}`))
21
+ }
22
+ for (const f of diff.modified) log(c.yellow(` modified ${f.path}`))
23
+ for (const [to, from] of diff.moves) log(c.cyan(` moved ${from} → ${to}`))
24
+ const movedFrom = new Set(diff.moves.values())
25
+ for (const path of diff.deleted) {
26
+ if (!movedFrom.has(path)) log(c.red(` deleted ${path}`))
27
+ }
28
+ if (scan.ignored.length) info(c.dim(` ${scan.ignored.length} ignored (.lecodesignore)`))
29
+ }
@@ -0,0 +1,370 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"
2
+ import { dirname, resolve } from "node:path"
3
+ import { compileHeadlessBundle } from "../compile/headlessBundle"
4
+ import { desktopDefaultViewport, resolveDesktopRenderer } from "./appShared"
5
+ import { fileScenarioIO, requireRenderer, resolveFixtures, resolveSafeAreaChoice } from "./render"
6
+ import { buildBundle } from "./compile"
7
+ import { findProjectRoot } from "../manifest"
8
+ import { runDesktopRender } from "../desktopRenderer"
9
+ import { evaluateDesktopRun, translateScenario } from "../desktopScript"
10
+ import { CliError, c, flagBool, flagStr, log, logErr, note, warnErr, type Args } from "../util"
11
+
12
+ /*
13
+ * `lecodes test` — run the project's headless flow tests (needs the optional `lecodes-renderer`
14
+ * package). Two sources, both executed as scenario step lists against a fresh session each:
15
+ *
16
+ * 1. `tests/*.flow.json` — hand-written scenarios (same schema as `lecodes render --script`:
17
+ * tap / type / scroll / wait / expect / screenshot / json steps).
18
+ * 2. The design graph: when `design/meta.json` exists, every edge with an `activator` between two
19
+ * real screens becomes a flow test — walk to the edge's `from` screen along activator edges
20
+ * from the start screen, tap the activator, expect a screen named `to`. This checks the BUILT
21
+ * app against the DESIGNED navigation — it requires the app to keep the design's `name`s on
22
+ * interactive elements and to name each screen root with its design screen id.
23
+ * Edges without an activator (backend-driven) or targeting an `@state` are reported as
24
+ * "unverifiable"; edges whose `from` has no activator path (e.g. tab-bar navigation, which is
25
+ * a surface, not edges) are "unreachable" — cover those with a hand-written flow file.
26
+ *
27
+ * A failing step stops its flow and dumps the failing frame (JSON + PNG when @napi-rs/canvas is
28
+ * installed) under tests/.artifacts/<flow>/ so a red test explains itself without a rerun.
29
+ */
30
+
31
+ type DesignEdge = { from: string, to: string, label?: string, activator?: string }
32
+ type DesignMeta = { screens?: Record<string, unknown>, edges?: DesignEdge[] }
33
+
34
+ type FlowCase = {
35
+ name: string
36
+ kind: "file" | "flow"
37
+ steps: unknown
38
+ /** Canned fetch responses for this case's session. File scenarios: their envelope `fixtures`
39
+ * (inline or a path). Design flows: the project pack (tests/fixtures.json / --fixtures). */
40
+ fixtures?: import("lecodes-renderer/headless").Fixtures
41
+ /** Per-file envelope overrides (file scenarios only). */
42
+ width?: number
43
+ height?: number
44
+ settle?: number
45
+ device?: string
46
+ safeArea?: string
47
+ }
48
+
49
+ type SkippedFlow = { name: string, reason: string }
50
+
51
+ /** Derive flow cases from the design graph. Returns the runnable flows + the ones that can't be
52
+ * machine-verified (documented, not failed). */
53
+ export const deriveDesignFlows = (meta: DesignMeta, entryScreen: string): { flows: FlowCase[], skipped: SkippedFlow[] } => {
54
+ const edges = meta.edges ?? []
55
+ const isState = (id: string) => id.includes("@")
56
+ const verifiable = edges.filter((e) => e.activator && !isState(e.from) && !isState(e.to))
57
+
58
+ // BFS shortest activator-path from the entry screen (over verifiable edges only).
59
+ const prev = new Map<string, DesignEdge>()
60
+ const visited = new Set<string>([entryScreen])
61
+ const queue = [entryScreen]
62
+ while (queue.length > 0) {
63
+ const at = queue.shift()!
64
+ for (const e of verifiable) {
65
+ if (e.from !== at || visited.has(e.to)) continue
66
+ visited.add(e.to)
67
+ prev.set(e.to, e)
68
+ queue.push(e.to)
69
+ }
70
+ }
71
+ const pathTo = (screen: string): DesignEdge[] | null => {
72
+ if (screen === entryScreen) return []
73
+ if (!visited.has(screen)) return null
74
+ const hops: DesignEdge[] = []
75
+ for (let at = screen; at !== entryScreen; at = hops[0].from) hops.unshift(prev.get(at)!)
76
+ return hops
77
+ }
78
+
79
+ const flows: FlowCase[] = []
80
+ const skipped: SkippedFlow[] = []
81
+ for (const e of edges) {
82
+ const name = `${e.from} —${e.activator ?? "?"}→ ${e.to}`
83
+ if (!e.activator) {
84
+ skipped.push({ name, reason: "no activator (backend/gesture-driven) — cover it with a tests/*.flow.json" })
85
+ continue
86
+ }
87
+ if (isState(e.from) || isState(e.to)) {
88
+ skipped.push({ name, reason: "screen-state edge (@state) — states are runtime conditions, not screens" })
89
+ continue
90
+ }
91
+ const path = pathTo(e.from)
92
+ if (path === null) {
93
+ skipped.push({ name, reason: `"${e.from}" has no activator path from "${entryScreen}" (tab-bar navigation?) — cover it with a tests/*.flow.json` })
94
+ continue
95
+ }
96
+ // A `fill` precedes every activator tap: submits are commonly disabled until their form is
97
+ // filled, and the graph tests NAVIGATION — dummy input satisfies local gates, the fixture pack
98
+ // answers what the submit then fetches. A no-op on screens without inputs.
99
+ flows.push({
100
+ name,
101
+ kind: "flow",
102
+ steps: [
103
+ ...path.flatMap((hop) => [{ fill: {} }, { tap: hop.activator!, expect: { screen: hop.to } }]),
104
+ { fill: {} },
105
+ { tap: e.activator, expect: { screen: e.to } },
106
+ ],
107
+ })
108
+ }
109
+ return { flows, skipped }
110
+ }
111
+
112
+ const slug = (s: string): string => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "case"
113
+
114
+ /** Read one tests/*.flow.json (envelope + steps, or a bare step array). */
115
+ const readFlowFile = (abs: string, displayName: string): FlowCase => {
116
+ let raw: unknown
117
+ try {
118
+ raw = JSON.parse(readFileSync(abs, "utf8"))
119
+ } catch (e) {
120
+ throw new CliError(`Can't read "${displayName}": ${e instanceof Error ? e.message : String(e)}`)
121
+ }
122
+ if (Array.isArray(raw)) return { name: displayName, kind: "file", steps: raw }
123
+ if (typeof raw !== "object" || raw === null || (raw as { steps?: unknown }).steps === undefined) {
124
+ throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`)
125
+ }
126
+ const file = raw as { name?: string, width?: number, height?: number, settle?: number, device?: string, safeArea?: string, fixtures?: Record<string, unknown> | string, steps: unknown }
127
+ return {
128
+ name: file.name ?? displayName, kind: "file", steps: file.steps,
129
+ fixtures: resolveFixtures(file.fixtures, dirname(abs)),
130
+ width: file.width, height: file.height, settle: file.settle, device: file.device, safeArea: file.safeArea,
131
+ }
132
+ }
133
+
134
+ export const test = async (args: Args) => {
135
+ // --desktop: run every file scenario through the NATIVE host (desktopScript.ts — coordinates
136
+ // only, expect.log checked, semantic expects reported skipped). Design-derived flows tap by
137
+ // activator NAME, which the host can't resolve, so they stay headless-only.
138
+ const desktop = flagBool(args, "desktop")
139
+ if (desktop && flagBool(args, "flows")) throw new CliError("--flows needs the headless renderer (design flows tap by name) — drop --desktop.")
140
+ // --desktop: ONE renderer decision (host binary + the staged .filamat variant). The root is
141
+ // recomputed here because `root` below comes from the headless bundle, which --desktop skips.
142
+ let desktopRoot: string
143
+ try { desktopRoot = findProjectRoot(process.cwd()) } catch { desktopRoot = process.cwd() }
144
+ const desktopRenderer = desktop ? resolveDesktopRenderer(desktopRoot, args) : undefined
145
+ const native = desktop ? await buildBundle(args, undefined, { localAssets: true, desktopRenderer }) : null
146
+ const headless = desktop ? null : await compileHeadlessBundle(args)
147
+ const warnings = headless?.warnings ?? []
148
+ const useLocalAssets = headless?.useLocalAssets ?? false
149
+ const js = headless?.js ?? ""
150
+ let root: string
151
+ if (headless) root = headless.root
152
+ else { try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() } }
153
+ const renderer = desktop ? null : await requireRenderer()
154
+ const nativeFrames = Number(flagStr(args, "frames")) || 60
155
+ const fixedDtFlag = flagStr(args, "fixed-dt")
156
+ const nativeFixedDt = fixedDtFlag !== undefined ? Number(fixedDtFlag) : 16
157
+ if (!Number.isFinite(nativeFixedDt) || nativeFixedDt < 0 || nativeFixedDt > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${fixedDtFlag}".`)
158
+ // Desktop-only projects (app.json desktop block, no ios/android) run flows desktop-sized.
159
+ const projectViewport = desktopDefaultViewport(root)
160
+
161
+ const testsDir = resolve(root, flagStr(args, "dir") ?? "tests")
162
+ const designMetaPath = resolve(root, flagStr(args, "design-dir") ?? "design", "meta.json")
163
+ const asJson = flagBool(args, "json")
164
+ const out = asJson ? logErr : log // --json owns stdout; the human report moves to stderr
165
+
166
+ // 1. File scenarios: the given paths, or every tests/*.flow.json.
167
+ const cases: FlowCase[] = []
168
+ if (args._.length > 0) {
169
+ for (const given of args._) {
170
+ const candidates = [resolve(given), resolve(testsDir, given), resolve(testsDir, `${given}.flow.json`)]
171
+ const abs = candidates.find((p) => existsSync(p))
172
+ if (!abs) throw new CliError(`No such test: "${given}" (looked in ${testsDir}).`)
173
+ cases.push(readFlowFile(abs, given))
174
+ }
175
+ } else if (existsSync(testsDir)) {
176
+ for (const f of readdirSync(testsDir).filter((f) => f.endsWith(".flow.json")).sort()) {
177
+ cases.push(readFlowFile(resolve(testsDir, f), f))
178
+ }
179
+ }
180
+
181
+ // The project fixture pack: backing data for the design-derived flows (whose success paths are
182
+ // usually backend-gated). `tests/fixtures.json` is picked up automatically; --fixtures overrides
183
+ // the path. File scenarios do NOT inherit it — they declare their own via the envelope, so a
184
+ // deliberately-offline scenario (error states) stays offline.
185
+ const fixturesFlag = flagStr(args, "fixtures")
186
+ const defaultPack = resolve(testsDir, "fixtures.json")
187
+ const flowFixtures = fixturesFlag !== undefined
188
+ ? resolveFixtures(fixturesFlag, ".")
189
+ : existsSync(defaultPack) ? resolveFixtures("fixtures.json", testsDir) : undefined
190
+
191
+ // 2. Design-graph flows (opt out with --no-flows; --flows runs ONLY these).
192
+ const flowsOnly = flagBool(args, "flows")
193
+ const wantFlows = !flagBool(args, "no-flows") && args._.length === 0
194
+ if (flowsOnly) cases.length = 0
195
+ let skipped: SkippedFlow[] = []
196
+ let entryScreen: string | null = null
197
+ if (desktop && wantFlows && existsSync(designMetaPath)) {
198
+ note("Design-derived flows skipped with --desktop (they tap by name; run `lecodes test` headless for them).")
199
+ }
200
+ if (!desktop && (wantFlows || flowsOnly) && existsSync(designMetaPath)) {
201
+ let meta: DesignMeta
202
+ try {
203
+ meta = JSON.parse(readFileSync(designMetaPath, "utf8")) as DesignMeta
204
+ } catch (e) {
205
+ throw new CliError(`Can't read ${designMetaPath}: ${e instanceof Error ? e.message : String(e)}`)
206
+ }
207
+ if ((meta.edges ?? []).length > 0) {
208
+ // Probe the app for its start screen — the BFS origin of every derived flow.
209
+ note("Probing the start screen…")
210
+ const probe = await renderer!.createSession(js, { raster: false, localAssets: useLocalAssets, fixtures: flowFixtures })
211
+ entryScreen = probe.screenName()
212
+ probe.dispose()
213
+ if (entryScreen === null) {
214
+ warnErr("Design flows skipped: the app's start screen root has no `name`. Name each screen root with its design screen id (e.g. UIScreen([...], { name: \"login\" })).")
215
+ } else {
216
+ const derived = deriveDesignFlows(meta, entryScreen)
217
+ cases.push(...derived.flows.map((f) => ({ ...f, fixtures: flowFixtures })))
218
+ skipped = derived.skipped
219
+ }
220
+ }
221
+ } else if (flowsOnly) {
222
+ throw new CliError(`--flows needs a design graph (${designMetaPath} not found).`)
223
+ }
224
+
225
+ if (cases.length === 0) {
226
+ for (const w of warnings) warnErr(w)
227
+ throw new CliError(`No tests found. Write tests/*.flow.json scenarios, or draw activator edges in the design board (design/meta.json).`)
228
+ }
229
+
230
+ // Hard timeout across the whole run (each case is bounded by settles, but a pathological app can
231
+ // still spin) — generous per-case allowance, overridable.
232
+ const timeoutMs = Number(flagStr(args, "timeout")) || (desktop ? 60000 + cases.length * 60000 : 20000 + cases.length * 10000)
233
+ const killer = setTimeout(() => {
234
+ warnErr(`Test run timed out after ${timeoutMs}ms (raise --timeout or lower --settle).`)
235
+ process.exit(1)
236
+ }, timeoutMs)
237
+ killer.unref?.()
238
+
239
+ const onConsole = flagBool(args, "logs")
240
+ ? (level: string, text: string) => logErr(`${c.dim(`[${level}]`)} ${text}`)
241
+ : undefined
242
+ const artifactsRoot = resolve(root, flagStr(args, "artifacts") ?? resolve(testsDir, ".artifacts"))
243
+
244
+ type CaseResult = {
245
+ name: string
246
+ kind: FlowCase["kind"]
247
+ ok: boolean
248
+ steps: { desc: string, ok: boolean, error?: string, skipped?: string[] }[]
249
+ error?: string
250
+ artifacts: string[]
251
+ }
252
+ const results: CaseResult[] = []
253
+
254
+ // Native case runner: each case is its own host process (fresh state, like a fresh session);
255
+ // per-step screenshots + the final frame land under the case's artifacts folder.
256
+ const runNativeCase = async (kase: FlowCase): Promise<CaseResult> => {
257
+ const caseDir = resolve(artifactsRoot, slug(kase.name))
258
+ const plan = translateScenario(kase.steps, { outDir: caseDir, headroomFrames: nativeFrames })
259
+ for (const st of plan.steps) if (st.screenshot) mkdirSync(dirname(st.screenshot), { recursive: true })
260
+ mkdirSync(caseDir, { recursive: true })
261
+ const finalPng = resolve(caseDir, "final.png")
262
+ const { output } = await runDesktopRender(native!.js, native!.resources, {
263
+ renderer: desktopRenderer,
264
+ width: Number(flagStr(args, "width")) || kase.width || projectViewport?.width || 390,
265
+ height: Number(flagStr(args, "height")) || kase.height || projectViewport?.height || 844,
266
+ frames: plan.frameCap, outPng: finalPng, logs: flagBool(args, "logs"),
267
+ timeoutMs: 60000 + plan.steps.length * 5000, script: plan.text, fixedDtMs: nativeFixedDt,
268
+ })
269
+ const r = evaluateDesktopRun(plan, output)
270
+ if (!r.loaded) throw new CliError("This desktop host doesn't support scripted input (CREATOR_SCRIPT). Update it: lecodes desktop update (or point LECODES_DESKTOP_EXE at a newer build).")
271
+ return {
272
+ name: kase.name, kind: kase.kind, ok: r.ok,
273
+ steps: r.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error, skipped: s.skipped.length > 0 ? s.skipped : undefined })),
274
+ error: r.ended ? undefined : "the host stopped before the script finished (frame cap / crash)",
275
+ artifacts: r.ok ? [] : [finalPng],
276
+ }
277
+ }
278
+
279
+ for (const kase of cases) {
280
+ const flagSettle = flagStr(args, "settle")
281
+ let result: CaseResult
282
+ if (desktop) {
283
+ try {
284
+ result = await runNativeCase(kase)
285
+ } catch (e) {
286
+ if (e instanceof CliError && /doesn't support scripted input/.test(e.message)) throw e
287
+ result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] }
288
+ }
289
+ results.push(result)
290
+ reportCase(result)
291
+ continue
292
+ }
293
+ try {
294
+ const steps = renderer!.parseScenarioSteps(kase.steps)
295
+ // Every case runs in a FRESH session — no state bleeds between flows.
296
+ const session = await renderer!.createSession(js, {
297
+ width: Number(flagStr(args, "width")) || kase.width || projectViewport?.width || 390,
298
+ height: Number(flagStr(args, "height")) || kase.height || projectViewport?.height || 844,
299
+ settleMs: flagSettle !== undefined ? Number(flagSettle) : kase.settle,
300
+ waitNetwork: flagBool(args, "wait-network"),
301
+ localAssets: useLocalAssets,
302
+ fixtures: kase.fixtures,
303
+ ...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? kase.safeArea, flagStr(args, "device") ?? kase.device),
304
+ onConsole,
305
+ })
306
+ try {
307
+ const io = fileScenarioIO(resolve(artifactsRoot, slug(kase.name)))
308
+ const outcome = await renderer!.runScenario(session, steps, io)
309
+ result = {
310
+ name: kase.name, kind: kase.kind, ok: outcome.ok,
311
+ steps: outcome.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error })),
312
+ artifacts: outcome.failureArtifacts.map((p) => resolve(artifactsRoot, slug(kase.name), p)),
313
+ }
314
+ } finally {
315
+ session.dispose()
316
+ }
317
+ } catch (e) {
318
+ // A malformed scenario / session crash is a failed case, not a dead run.
319
+ result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] }
320
+ }
321
+ results.push(result)
322
+ reportCase(result)
323
+ }
324
+
325
+ function reportCase(result: CaseResult): void {
326
+ const skippedCount = result.steps.reduce((n, s) => n + (s.skipped?.length ?? 0), 0)
327
+ if (result.ok) {
328
+ out(`${c.green("✓")} ${result.name}${skippedCount > 0 ? c.dim(` (${skippedCount} semantic check${skippedCount === 1 ? "" : "s"} skipped on --desktop)`) : ""}`)
329
+ } else {
330
+ out(`${c.red("✗")} ${result.name}`)
331
+ if (result.error) out(` ${c.red(result.error)}`)
332
+ for (const s of result.steps) {
333
+ out(s.ok ? c.dim(` ✓ ${s.desc}`) : ` ${c.red(`✗ ${s.desc} — ${s.error}`)}`)
334
+ for (const sk of s.skipped ?? []) out(c.dim(` – skipped: ${sk}`))
335
+ }
336
+ if (result.artifacts.length > 0) out(c.dim(` failing frame: ${result.artifacts.join(", ")}`))
337
+ }
338
+ }
339
+
340
+ for (const w of warnings) warnErr(w)
341
+ const passed = results.filter((r) => r.ok).length
342
+ const failed = results.length - passed
343
+ if (skipped.length > 0) {
344
+ out("")
345
+ out(c.dim(`Not machine-verifiable (${skipped.length}):`))
346
+ for (const s of skipped) out(c.dim(` - ${s.name}: ${s.reason}`))
347
+ }
348
+ out("")
349
+ const flowCount = results.filter((r) => r.kind === "flow").length
350
+ const summary = [
351
+ `${passed} passed`,
352
+ failed > 0 ? c.red(`${failed} failed`) : undefined,
353
+ skipped.length > 0 ? `${skipped.length} unverifiable` : undefined,
354
+ flowCount > 0 ? c.dim(`(${flowCount} from the design graph${entryScreen ? `, start: ${entryScreen}` : ""})`) : undefined,
355
+ ].filter(Boolean).join(", ")
356
+ out(failed > 0 ? `${c.red("✗")} ${summary}` : `${c.green("✓")} ${summary}`)
357
+
358
+ if (asJson) {
359
+ process.stdout.write(JSON.stringify({
360
+ ok: failed === 0,
361
+ passed, failed,
362
+ entryScreen,
363
+ cases: results,
364
+ unverifiable: skipped,
365
+ }, null, 2) + "\n")
366
+ }
367
+
368
+ clearTimeout(killer)
369
+ process.exit(failed > 0 ? 1 : 0)
370
+ }