lecodes-cli 0.19.0 → 0.20.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 (270) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +1069 -396
  3. package/package.json +14 -13
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk-types.json +1 -1
  6. package/runtime/web/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
  7. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  8. package/runtime/web/assets/basis-C64VHDVD.js +1 -0
  9. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  10. package/runtime/web/assets/createViewerLite-BvHobVhp.js +852 -0
  11. package/runtime/web/assets/draco-BiISTFcR.js +118 -0
  12. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  13. package/runtime/web/assets/index-CfNCCBjN.js +2 -0
  14. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +810 -0
  15. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +8 -0
  16. package/runtime/web/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  17. package/runtime/web/assets/worker-Caf-yYEI.js +2 -0
  18. package/runtime/web/embed.html +32 -0
  19. package/runtime/web/embed.js +171 -0
  20. package/src/api.ts +302 -0
  21. package/src/browserAuth.ts +87 -0
  22. package/src/cmgenTool.ts +104 -0
  23. package/src/commands/app.ts +896 -0
  24. package/src/commands/appAndroid.ts +603 -0
  25. package/src/commands/appDesktop.ts +339 -0
  26. package/src/commands/appDesktopMac.ts +470 -0
  27. package/src/commands/appIcon.ts +187 -0
  28. package/src/commands/appShared.ts +542 -0
  29. package/src/commands/appTemplates.ts +907 -0
  30. package/src/commands/appTemplatesAndroid.ts +599 -0
  31. package/src/commands/appTemplatesGradlew.ts +9 -0
  32. package/src/commands/assets.ts +28 -0
  33. package/src/commands/clone.ts +60 -0
  34. package/src/commands/compile.ts +253 -0
  35. package/src/commands/create.ts +59 -0
  36. package/src/commands/design.ts +863 -0
  37. package/src/commands/designTemplates.ts +11 -0
  38. package/src/commands/desktop.ts +127 -0
  39. package/src/commands/dev.ts +236 -0
  40. package/src/commands/diff.ts +59 -0
  41. package/src/commands/init.ts +191 -0
  42. package/src/commands/install.ts +198 -0
  43. package/src/commands/lightmap.ts +290 -0
  44. package/src/commands/link.ts +147 -0
  45. package/src/commands/login.ts +24 -0
  46. package/src/commands/navmesh.ts +225 -0
  47. package/src/commands/pn.ts +305 -0
  48. package/src/commands/projectTemplates.ts +130 -0
  49. package/src/commands/pull.ts +109 -0
  50. package/src/commands/push.ts +173 -0
  51. package/src/commands/render.ts +416 -0
  52. package/src/commands/scene.ts +148 -0
  53. package/src/commands/shaders.ts +157 -0
  54. package/src/commands/status.ts +29 -0
  55. package/src/commands/test.ts +371 -0
  56. package/src/commands/thumbs.ts +178 -0
  57. package/src/commands/types.ts +87 -0
  58. package/src/commands/update.ts +190 -0
  59. package/src/compile/assetIcons.ts +214 -0
  60. package/src/compile/collect.ts +46 -0
  61. package/src/compile/collectLocal.ts +37 -0
  62. package/src/compile/designCompile.ts +104 -0
  63. package/src/compile/fonts.ts +156 -0
  64. package/src/compile/headlessBundle.ts +129 -0
  65. package/src/compile/nativeStack.ts +21 -0
  66. package/src/compile/projectCompile.ts +109 -0
  67. package/src/compile/sceneCompile.ts +113 -0
  68. package/src/compile/screenEntry.ts +127 -0
  69. package/src/compile/shaders.ts +245 -0
  70. package/src/config.ts +42 -0
  71. package/src/designMeta.ts +35 -0
  72. package/src/desktopRenderer.ts +545 -0
  73. package/src/desktopScript.ts +276 -0
  74. package/src/dev/clientTemplates.ts +160 -0
  75. package/src/dev/devServer.ts +333 -0
  76. package/src/dev/webRunner.ts +22 -0
  77. package/src/dev/wsServer.ts +144 -0
  78. package/src/distRoot.ts +20 -0
  79. package/src/ignore.ts +163 -0
  80. package/src/index.ts +494 -0
  81. package/src/lecodes-3d-editor.d.ts +41 -0
  82. package/src/lecodes-assets.d.ts +7 -0
  83. package/src/lecodes-design.d.ts +191 -0
  84. package/src/lecodes-renderer.d.ts +131 -0
  85. package/src/localFiles.ts +144 -0
  86. package/src/manifest.ts +46 -0
  87. package/src/matcTool.ts +137 -0
  88. package/src/peers.ts +40 -0
  89. package/src/project.ts +61 -0
  90. package/src/projectEnv.ts +94 -0
  91. package/src/qrcode-terminal.d.ts +9 -0
  92. package/src/releases.ts +125 -0
  93. package/src/serverDiff.ts +78 -0
  94. package/src/textDiff.ts +103 -0
  95. package/src/types.ts +0 -0
  96. package/src/util.ts +146 -0
  97. package/runtime/sdk/animate/animate.ts +0 -238
  98. package/runtime/sdk/animate/bezier.ts +0 -138
  99. package/runtime/sdk/animate/easings.ts +0 -126
  100. package/runtime/sdk/canvas/Canvas.ts +0 -305
  101. package/runtime/sdk/compile/aspectMacro.ts +0 -86
  102. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  103. package/runtime/sdk/compile/assetMacro.ts +0 -146
  104. package/runtime/sdk/compile/assetName.ts +0 -50
  105. package/runtime/sdk/compile/bundler.ts +0 -287
  106. package/runtime/sdk/compile/compileProject.ts +0 -129
  107. package/runtime/sdk/compile/detectEntry.ts +0 -128
  108. package/runtime/sdk/compile/fontMacro.ts +0 -459
  109. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  110. package/runtime/sdk/compile/header.ts +0 -67
  111. package/runtime/sdk/compile/index.ts +0 -100
  112. package/runtime/sdk/compile/libraryImports.ts +0 -52
  113. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  114. package/runtime/sdk/compile/sceneEditor.ts +0 -88
  115. package/runtime/sdk/compile/serverSplit.ts +0 -233
  116. package/runtime/sdk/compile/serverTypes.ts +0 -227
  117. package/runtime/sdk/compile/sfnt.ts +0 -98
  118. package/runtime/sdk/compile/shaderSchema.ts +0 -202
  119. package/runtime/sdk/compile/shaderTargets.ts +0 -81
  120. package/runtime/sdk/compile/sourcemap.ts +0 -25
  121. package/runtime/sdk/core/Aspect.ts +0 -568
  122. package/runtime/sdk/core/InspectorUI.ts +0 -212
  123. package/runtime/sdk/core/StateMachine.ts +0 -308
  124. package/runtime/sdk/core/color.ts +0 -66
  125. package/runtime/sdk/core/compWrite.ts +0 -42
  126. package/runtime/sdk/core/events.ts +0 -38
  127. package/runtime/sdk/core/fields.ts +0 -120
  128. package/runtime/sdk/core/registry.ts +0 -23
  129. package/runtime/sdk/core/signals.ts +0 -277
  130. package/runtime/sdk/core/time.ts +0 -84
  131. package/runtime/sdk/g2/Camera2D.ts +0 -40
  132. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  133. package/runtime/sdk/g2/Node2D.ts +0 -267
  134. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  135. package/runtime/sdk/g2/Physics2D.ts +0 -346
  136. package/runtime/sdk/g2/Scene2D.ts +0 -209
  137. package/runtime/sdk/g2/Shape2D.ts +0 -259
  138. package/runtime/sdk/g2/Sprite.ts +0 -89
  139. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  140. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  141. package/runtime/sdk/g2/Texture2D.ts +0 -47
  142. package/runtime/sdk/g2/Tilemap.ts +0 -41
  143. package/runtime/sdk/g2/Tileset.ts +0 -71
  144. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  145. package/runtime/sdk/g2/autotile.ts +0 -433
  146. package/runtime/sdk/g2/cells.ts +0 -91
  147. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  148. package/runtime/sdk/g2/groups2d.ts +0 -106
  149. package/runtime/sdk/g2/loop.ts +0 -50
  150. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  151. package/runtime/sdk/g2/touch.ts +0 -83
  152. package/runtime/sdk/gl/Camera.ts +0 -160
  153. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  154. package/runtime/sdk/gl/CharacterController.ts +0 -238
  155. package/runtime/sdk/gl/Gearbox.ts +0 -212
  156. package/runtime/sdk/gl/Geometry.ts +0 -279
  157. package/runtime/sdk/gl/IK.ts +0 -193
  158. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  159. package/runtime/sdk/gl/Light.ts +0 -107
  160. package/runtime/sdk/gl/Lightmap.ts +0 -256
  161. package/runtime/sdk/gl/Material.ts +0 -260
  162. package/runtime/sdk/gl/Mesh.ts +0 -83
  163. package/runtime/sdk/gl/Model.ts +0 -124
  164. package/runtime/sdk/gl/NavAgent.ts +0 -337
  165. package/runtime/sdk/gl/NavMesh.ts +0 -397
  166. package/runtime/sdk/gl/Node.ts +0 -350
  167. package/runtime/sdk/gl/Noise.ts +0 -30
  168. package/runtime/sdk/gl/Particles.ts +0 -676
  169. package/runtime/sdk/gl/Physics.ts +0 -222
  170. package/runtime/sdk/gl/Plane.ts +0 -53
  171. package/runtime/sdk/gl/Ragdoll.ts +0 -270
  172. package/runtime/sdk/gl/Ray.ts +0 -16
  173. package/runtime/sdk/gl/Scene.ts +0 -486
  174. package/runtime/sdk/gl/Shape.ts +0 -409
  175. package/runtime/sdk/gl/Terrain.ts +0 -1092
  176. package/runtime/sdk/gl/Texture.ts +0 -63
  177. package/runtime/sdk/gl/Trigger.ts +0 -45
  178. package/runtime/sdk/gl/Vehicle.ts +0 -473
  179. package/runtime/sdk/gl/Wheel.ts +0 -240
  180. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  181. package/runtime/sdk/gl/animation/Animator.ts +0 -120
  182. package/runtime/sdk/gl/animation/Layer.ts +0 -31
  183. package/runtime/sdk/gl/animation/Loop.ts +0 -30
  184. package/runtime/sdk/gl/animation/Playback.ts +0 -44
  185. package/runtime/sdk/gl/animation/core.ts +0 -336
  186. package/runtime/sdk/gl/controls.ts +0 -95
  187. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  188. package/runtime/sdk/gl/scenarios.ts +0 -291
  189. package/runtime/sdk/gl/state.ts +0 -6
  190. package/runtime/sdk/gl/terrainMesh.ts +0 -219
  191. package/runtime/sdk/gl/touch.ts +0 -68
  192. package/runtime/sdk/inject.ts +0 -197
  193. package/runtime/sdk/math/Mathf.ts +0 -118
  194. package/runtime/sdk/math/mat4.ts +0 -278
  195. package/runtime/sdk/math/quat.ts +0 -232
  196. package/runtime/sdk/math/vec.ts +0 -255
  197. package/runtime/sdk/net/codec.ts +0 -119
  198. package/runtime/sdk/net/core.ts +0 -384
  199. package/runtime/sdk/net/index.ts +0 -181
  200. package/runtime/sdk/net/replication.ts +0 -622
  201. package/runtime/sdk/plugins/camera.ts +0 -81
  202. package/runtime/sdk/plugins/geolocation.ts +0 -123
  203. package/runtime/sdk/plugins/oauth.ts +0 -61
  204. package/runtime/sdk/plugins/permission.ts +0 -7
  205. package/runtime/sdk/plugins/push.ts +0 -132
  206. package/runtime/sdk/plugins/qr.ts +0 -73
  207. package/runtime/sdk/plugins/service.ts +0 -47
  208. package/runtime/sdk/runtime/app.ts +0 -101
  209. package/runtime/sdk/runtime/appEvents.ts +0 -54
  210. package/runtime/sdk/runtime/channel.ts +0 -61
  211. package/runtime/sdk/runtime/clipboard.ts +0 -20
  212. package/runtime/sdk/runtime/datetime.ts +0 -329
  213. package/runtime/sdk/runtime/device.ts +0 -293
  214. package/runtime/sdk/runtime/fetch.ts +0 -77
  215. package/runtime/sdk/runtime/files.ts +0 -23
  216. package/runtime/sdk/runtime/input.ts +0 -175
  217. package/runtime/sdk/runtime/media.ts +0 -111
  218. package/runtime/sdk/runtime/misc.ts +0 -16
  219. package/runtime/sdk/runtime/net.ts +0 -36
  220. package/runtime/sdk/runtime/rpc.ts +0 -218
  221. package/runtime/sdk/runtime/service.ts +0 -83
  222. package/runtime/sdk/runtime/share.ts +0 -9
  223. package/runtime/sdk/runtime/storage.ts +0 -13
  224. package/runtime/sdk/runtime/touch.ts +0 -76
  225. package/runtime/sdk/scene/defineScene.ts +0 -1353
  226. package/runtime/sdk/scene/editorPlugins.ts +0 -110
  227. package/runtime/sdk/scene/gizmos.ts +0 -148
  228. package/runtime/sdk/scene/grammar.ts +0 -120
  229. package/runtime/sdk/scene/material.ts +0 -188
  230. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  231. package/runtime/sdk/server/auth/global.ts +0 -80
  232. package/runtime/sdk/server/auth/host.ts +0 -318
  233. package/runtime/sdk/server/auth/models.ts +0 -83
  234. package/runtime/sdk/server/auth/types.ts +0 -50
  235. package/runtime/sdk/server/channel.ts +0 -56
  236. package/runtime/sdk/server/context.ts +0 -36
  237. package/runtime/sdk/server/db/defineDb.ts +0 -237
  238. package/runtime/sdk/server/db/fields.ts +0 -132
  239. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  240. package/runtime/sdk/server/db/index.ts +0 -7
  241. package/runtime/sdk/server/db/marci/query.ts +0 -412
  242. package/runtime/sdk/server/db/types.ts +0 -202
  243. package/runtime/sdk/server/errors.ts +0 -12
  244. package/runtime/sdk/server/host.ts +0 -74
  245. package/runtime/sdk/server/inject.ts +0 -13
  246. package/runtime/sdk/server/runtime.ts +0 -133
  247. package/runtime/sdk/server/validate.ts +0 -87
  248. package/runtime/sdk/ui/NativeView.ts +0 -142
  249. package/runtime/sdk/ui/UI.ts +0 -39
  250. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  251. package/runtime/sdk/ui/UIButton.ts +0 -101
  252. package/runtime/sdk/ui/UIContainer.ts +0 -60
  253. package/runtime/sdk/ui/UIImage.ts +0 -83
  254. package/runtime/sdk/ui/UIInput.ts +0 -185
  255. package/runtime/sdk/ui/UIModal.ts +0 -139
  256. package/runtime/sdk/ui/UINode.ts +0 -830
  257. package/runtime/sdk/ui/UIPager.ts +0 -362
  258. package/runtime/sdk/ui/UIPopover.ts +0 -100
  259. package/runtime/sdk/ui/UIScreen.ts +0 -123
  260. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  261. package/runtime/sdk/ui/UISpacer.ts +0 -14
  262. package/runtime/sdk/ui/UITabs.ts +0 -236
  263. package/runtime/sdk/ui/UIText.ts +0 -51
  264. package/runtime/sdk/ui/UIVideo.ts +0 -88
  265. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  266. package/runtime/sdk/ui/UIWidget.ts +0 -127
  267. package/runtime/sdk/ui/fonts.ts +0 -13
  268. package/runtime/sdk/ui/presentable.ts +0 -117
  269. package/runtime/sdk/ui/router.ts +0 -132
  270. package/runtime/sdk/ui/theme.ts +0 -84
@@ -0,0 +1,225 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { basename, join, relative, resolve } from "node:path"
4
+ import { findProjectRoot } from "../manifest"
5
+ import { buildBundle } from "./compile"
6
+ import { readAppConfigAt, resolveDesktopRenderer } from "./appShared"
7
+ import { runDesktopRender } from "../desktopRenderer"
8
+ import { CliError, c, flagStr, flagBool, logErr, note, warnErr, type Args } from "../util"
9
+ import { loadCreatorNav, NavBuilder, readNgeo, navMeshTriangles, type BuildParams } from "lecodes-creator-nav"
10
+
11
+ /*
12
+ * `lecodes navmesh bake` — build the level's navigation mesh (docs/navmesh-plan.md §4). Two halves:
13
+ *
14
+ * 1. The app is compiled with a synthetic entry that puts it in BAKE mode
15
+ * (`globalThis.__lecodesNavmesh = { outDir, name }`, then `import "./main"`) and run in the
16
+ * native desktop host; when the level is complete — the scene file's `env.navmesh`, or a
17
+ * hand-written `NavMesh.load(...)` — the SDK calls `_creator.physicsStaticGeometry` instead of
18
+ * loading: every STATIC Jolt body's triangles land in `<out>/<name>.navgeo`, and the host's
19
+ * stdout carries `[navmesh] geometry …` / `[navmesh] config {…}` / `[navmesh] done`.
20
+ * 2. Recast runs HERE (creator-nav's wasm): the dump + the scene's agent size (+ the flags below)
21
+ * → a tiled Detour navmesh → `<out>/<name>.navmesh` (LNAV: header JSON + tiles).
22
+ *
23
+ * Running the build in the CLI keeps the host generic (a geometry dump, useful on its own),
24
+ * lets the bake parameters iterate without a host rebuild, and is the same code a future headless
25
+ * bake reuses. The desktop host must know the dump (built from packages/desktop, or a release that
26
+ * ships it: LECODES_DESKTOP_EXE for a local build until then).
27
+ */
28
+
29
+ const num = (args: Args, key: string, fallback: number, min: number, max: number): number => {
30
+ const raw = flagStr(args, key)
31
+ if (raw === undefined) return fallback
32
+ const n = Number(raw)
33
+ if (!Number.isFinite(n) || n < min || n > max) throw new CliError(`--${key} wants a number in ${min}..${max}, got "${raw}".`)
34
+ return n
35
+ }
36
+
37
+ type BakeConfigLine = {
38
+ agent: { radius: number, height: number, climb: number, slope: number }
39
+ areas: Record<string, number>
40
+ costs: Record<string, number>
41
+ data?: string
42
+ }
43
+
44
+ /** The first `*.scene.ts` under src/ (or the root) that declares `navmesh:` names the output by default. */
45
+ const guessName = (root: string): string | null => {
46
+ const dirs = [ join(root, "src"), root ]
47
+ for (const dir of dirs) {
48
+ if (!existsSync(dir)) continue
49
+ let entries: string[]
50
+ try { entries = readdirSync(dir) } catch { continue }
51
+ for (const f of entries) {
52
+ if (!f.endsWith(".scene.ts")) continue
53
+ try {
54
+ if (/\bnavmesh\s*:/.test(readFileSync(join(dir, f), "utf8"))) return f.slice(0, -".scene.ts".length)
55
+ } catch { /* skip */ }
56
+ }
57
+ }
58
+ return null
59
+ }
60
+
61
+ /** An empty LNAV (header only, one dummy tile slot) so the compiler can stage the file before the
62
+ * first bake; NavMesh.load reads `stats.polys === 0` as "placeholder — rebake". */
63
+ const placeholderLnav = (): Buffer => {
64
+ const json = Buffer.from(JSON.stringify({ version: 1, placeholder: true, stats: { tiles: 0, polys: 0 } }), "utf8")
65
+ const jsonPadded = Buffer.alloc(Math.ceil(json.length / 4) * 4)
66
+ json.copy(jsonPadded)
67
+ const head = Buffer.alloc(12)
68
+ head.writeUInt32LE(0x56414e4c, 0) // 'LNAV'
69
+ head.writeUInt32LE(1, 4)
70
+ head.writeUInt32LE(json.length, 8)
71
+ // dtNavMeshParams: orig[3] f32, tileWidth, tileHeight f32, maxTiles, maxPolys i32
72
+ const params = Buffer.alloc(28)
73
+ params.writeFloatLE(1, 12)
74
+ params.writeFloatLE(1, 16)
75
+ params.writeInt32LE(1, 20)
76
+ params.writeInt32LE(1, 24)
77
+ const tiles = Buffer.alloc(4)
78
+ return Buffer.concat([ head, jsonPadded, params, tiles ])
79
+ }
80
+
81
+ const writeObj = (path: string, tris: Float32Array): void => {
82
+ const lines: string[] = [ "# navmesh polygons — lecodes navmesh bake --obj" ]
83
+ for (let i = 0; i < tris.length; i += 3) lines.push(`v ${tris[i]!.toFixed(4)} ${tris[i + 1]!.toFixed(4)} ${tris[i + 2]!.toFixed(4)}`)
84
+ for (let i = 0; i < tris.length / 3; i += 3) lines.push(`f ${i + 1} ${i + 2} ${i + 3}`)
85
+ writeFileSync(path, lines.join("\n") + "\n")
86
+ }
87
+
88
+ const bake = async (args: Args): Promise<void> => {
89
+ let root: string
90
+ try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() }
91
+ const outDir = resolve(root, flagStr(args, "out") ?? "assets/nav")
92
+ const entryFlag = flagStr(args, "entry")
93
+ const entryFile = entryFlag ?? "main.ts"
94
+ if (!existsSync(resolve(root, entryFile))) {
95
+ throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load).`)
96
+ }
97
+ const name = flagStr(args, "name") ?? guessName(root) ?? "scene"
98
+ const frames = num(args, "frames", 900, 30, 100000)
99
+ const win = readAppConfigAt(root)?.desktop?.window
100
+ const width = Number(flagStr(args, "width")) || win?.width || 1280
101
+ const height = Number(flagStr(args, "height")) || win?.height || 720
102
+ const logs = flagBool(args, "logs")
103
+ const keepGeometry = flagBool(args, "keep-geometry")
104
+ const obj = flagBool(args, "obj")
105
+
106
+ // The navmesh file must exist BEFORE the compile: the scene references it through asset(), and a
107
+ // missing asset is a broken bundle. The first bake starts from a placeholder the real file overwrites.
108
+ mkdirSync(outDir, { recursive: true })
109
+ const navmeshPath = join(outDir, `${name}.navmesh`)
110
+ if (!existsSync(navmeshPath)) writeFileSync(navmeshPath, placeholderLnav())
111
+
112
+ const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
113
+ const entryPath = "/.navmesh-entry.ts"
114
+ const cfg = { outDir: outDir.replace(/\\/g, "/"), name }
115
+ const entryText = `globalThis.__lecodesNavmesh = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
116
+
117
+ const desktopRenderer = resolveDesktopRenderer(root, args)
118
+ const { js, resources } = await buildBundle(args, undefined, {
119
+ localAssets: true, desktopRenderer,
120
+ extraEntries: [{ path: entryPath, type: "text", text: entryText }],
121
+ entryOverride: entryPath,
122
+ })
123
+
124
+ const scratch = mkdtempSync(join(tmpdir(), "lecodes-navmesh-"))
125
+ const outPng = join(scratch, "frame.png")
126
+ note(`Dumping the level's static collision geometry through the desktop host → ${relative(root, outDir) || "."}/${name}.navgeo…`)
127
+ let output = ""
128
+ try {
129
+ const timeoutMs = Number(flagStr(args, "timeout")) || 600000
130
+ const res = await runDesktopRender(js, resources, {
131
+ renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs,
132
+ stopOnLine: (chunk) => chunk.includes("[navmesh] done") || chunk.includes("[navmesh] error"),
133
+ })
134
+ output = res.output
135
+ } catch (e) {
136
+ const msg = e instanceof Error ? e.message : String(e)
137
+ if (!/\[navmesh\] done/.test(msg)) throw e
138
+ output = msg
139
+ } finally {
140
+ rmSync(scratch, { recursive: true, force: true })
141
+ }
142
+
143
+ // the markers are console.log lines from the SDK — the host prefixes them ("[DEBUG] [navmesh] …")
144
+ const lines = output.split(/\r?\n/).filter((l) => l.includes("[navmesh]")).map((l) => l.slice(l.indexOf("[navmesh]")))
145
+ let config: BakeConfigLine | null = null
146
+ let triangles = 0
147
+ for (const l of lines) {
148
+ const text = l.slice("[navmesh]".length).trim()
149
+ if (text === "done") continue
150
+ if (text.startsWith("config ")) { try { config = JSON.parse(text.slice(7)) as BakeConfigLine } catch { /* reported below */ } continue }
151
+ const geo = /^geometry (\d+) triangles/.exec(text)
152
+ if (geo) triangles = Number(geo[1])
153
+ logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : c.dim(` ${text}`))
154
+ }
155
+ if (!/\[navmesh\] done/.test(output)) {
156
+ if (lines.some((l) => /cannot dump collision geometry/.test(l))) {
157
+ throw new CliError("This desktop host has no collision-geometry dump. Build packages/desktop and point LECODES_DESKTOP_EXE at build/lecodes-desktop.exe, or update: lecodes desktop update.")
158
+ }
159
+ if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
160
+ warnErr("The host ran to its frame cap without dumping geometry. Make sure the level declares `env.navmesh` (a scene file) or calls NavMesh.load(...) after its static bodies exist, and skips menus when NavMesh.baking is true. More load headroom: --frames <n>. Host output: --logs.")
161
+ throw new CliError("Nothing was baked.")
162
+ }
163
+ if (!config) throw new CliError("The host reported a dump but no `[navmesh] config` line — SDK/host mismatch?")
164
+ const geoPath = join(outDir, `${name}.navgeo`)
165
+ if (!existsSync(geoPath)) throw new CliError(`The host reported a dump but ${geoPath} is missing.`)
166
+
167
+ // ---- Recast (wasm) ----
168
+ const geo = readNgeo(new Uint8Array(readFileSync(geoPath)))
169
+ const params: BuildParams = {
170
+ agentRadius: num(args, "radius", config.agent.radius, 0.05, 10),
171
+ agentHeight: num(args, "height", config.agent.height, 0.1, 20),
172
+ agentMaxClimb: num(args, "climb", config.agent.climb, 0, 10),
173
+ agentMaxSlope: num(args, "slope", config.agent.slope, 0, 89),
174
+ cellSize: num(args, "cell", 0.25, 0.01, 5),
175
+ cellHeight: num(args, "cell-height", 0.1, 0.01, 5),
176
+ tileSize: num(args, "tile", 48, 0, 1024),
177
+ regionMinSize: num(args, "region-min", 8, 0, 1000),
178
+ regionMergeSize: num(args, "region-merge", 20, 0, 1000),
179
+ edgeMaxLen: num(args, "edge-max", 12, 0, 1000),
180
+ edgeMaxError: num(args, "edge-error", 1.3, 0.1, 10),
181
+ detailSampleDist: num(args, "detail", 6, 0, 100),
182
+ detailMaxError: num(args, "detail-error", 1, 0, 100),
183
+ }
184
+ note(`Building the navmesh: ${geo.triangleCount} triangles from ${geo.bodyCount} static bodies, agent r ${params.agentRadius} / h ${params.agentHeight} / climb ${params.agentMaxClimb} / slope ${params.agentMaxSlope}°, cell ${params.cellSize} m…`)
185
+ const mod = await loadCreatorNav()
186
+ const builder = new NavBuilder(mod, params)
187
+ let result
188
+ try {
189
+ builder.addSoup(geo.triangles, geo.areas)
190
+ const meta = {
191
+ scene: name, areas: config.areas, costs: config.costs, bakedAt: new Date().toISOString(),
192
+ params, triangles: geo.triangleCount, bodies: geo.bodyCount,
193
+ }
194
+ let lastTile = -1
195
+ result = builder.run(meta, (tile, count, message) => {
196
+ if (message) logErr(c.dim(` recast: ${message}`))
197
+ else if (count > 8 && tile !== lastTile && (tile + 1) % Math.max(1, Math.floor(count / 8)) === 0) { lastTile = tile; logErr(c.dim(` tile ${tile + 1}/${count}`)) }
198
+ })
199
+ } finally {
200
+ builder.destroy()
201
+ }
202
+ if (result.polys === 0) {
203
+ throw new CliError("The build produced no polygons — nothing walkable for this agent. Check the cell size against the level's scale, the slope, and that the floor is a STATIC body (Shape + Physics { motion: 'static' }).")
204
+ }
205
+ writeFileSync(navmeshPath, result.data)
206
+ if (obj) {
207
+ const objPath = join(outDir, `${name}.navmesh.obj`)
208
+ writeObj(objPath, navMeshTriangles(mod, result.data))
209
+ logErr(c.dim(` wrote ${relative(root, objPath)} (the polygon mesh, for inspection)`))
210
+ }
211
+ if (!keepGeometry) unlinkSync(geoPath)
212
+ const kb = Math.round(statSync(navmeshPath).size / 1024)
213
+ const b = result.bounds
214
+ logErr(c.dim(` ${result.tiles} tiles, ${result.polys} polygons, ${(b.max[0] - b.min[0]).toFixed(0)} × ${(b.max[2] - b.min[2]).toFixed(0)} m, ${result.time.toFixed(1)} s`))
215
+ logErr(c.green(`✓ baked → ${relative(root, navmeshPath).replace(/\\/g, "/")} (${kb} KB)`))
216
+ if (!config.data) {
217
+ logErr(c.dim(` load it with env.navmesh: { data: asset('<rel>/${basename(navmeshPath)}') } in the scene file, or NavMesh.load(scene, { data: asset(...) })`))
218
+ }
219
+ }
220
+
221
+ export const navmesh = async (args: Args): Promise<void> => {
222
+ const sub = args._[0]
223
+ if (sub === "bake") return bake({ ...args, _: args._.slice(1) })
224
+ throw new CliError("Usage: lecodes navmesh bake [--out assets/nav] [--name <scene>] [--entry main.ts] [--radius 0.4] [--height 1.8] [--climb 0.4] [--slope 50] [--cell 0.25] [--cell-height 0.1] [--tile 48] [--obj] [--keep-geometry] [--frames 900] [--logs]")
225
+ }
@@ -0,0 +1,305 @@
1
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import {
4
+ createPushKey, forgetPushDevice, listPushDevices, listPushKeys, revokePushKey, sendPush,
5
+ type PushDevice, type PushSendPayload,
6
+ } from "../api"
7
+ import { loadConfig, requireApiUrl, requireToken } from "../config"
8
+ import { findProjectRoot, readManifest } from "../manifest"
9
+ import { CliError, c, confirm, flagBool, flagStr, info, log, success, warn, type Args } from "../util"
10
+
11
+ /*
12
+ * `lecodes pn` — push notifications for this project (docs/push-plan.md). Named `pn` because
13
+ * `lecodes push` is already the code-upload command.
14
+ *
15
+ * pn send test-send using the PAT from `lecodes login` — /push/send accepts a
16
+ * developer-role user token exactly like a project push key, so the dev loop
17
+ * needs NO key at all.
18
+ * pn devices which devices actually registered — the first thing to check when a push
19
+ * doesn't arrive (an empty list means the app never called Push.register()).
20
+ * pn keys mint / list / revoke the `lecodes_pk_…` keys. The SERVER generates these;
21
+ * the plaintext exists in the mint response and nowhere else, so `keys new`
22
+ * is the only moment it can be captured (--env captures it for you).
23
+ *
24
+ * Everything is scoped to the project in .lecodes/manifest.json — no uuid to look up.
25
+ */
26
+
27
+ const ENV_VAR = "LECODES_PUSH_KEY"
28
+
29
+ type Context = { root: string, uuid: string, name: string, apiUrl: string, token: string }
30
+
31
+ const context = (): Context => {
32
+ const root = findProjectRoot(process.cwd())
33
+ const manifest = readManifest(root)
34
+ const config = loadConfig()
35
+ return { root, uuid: manifest.uuid, name: manifest.name, apiUrl: requireApiUrl(config), token: requireToken(config) }
36
+ }
37
+
38
+ /** A compact "3h ago" — absolute timestamps make these tables unreadable at a glance. */
39
+ const ago = (iso: string | null): string => {
40
+ if (!iso) return c.dim("never")
41
+ const ms = Date.now() - new Date(iso).getTime()
42
+ if (!Number.isFinite(ms)) return c.dim("—")
43
+ const mins = Math.round(ms / 60_000)
44
+ if (mins < 1) return "just now"
45
+ if (mins < 60) return `${mins}m ago`
46
+ const hours = Math.round(mins / 60)
47
+ if (hours < 24) return `${hours}h ago`
48
+ return `${Math.round(hours / 24)}d ago`
49
+ }
50
+
51
+ // Column widths must count PRINTED characters: on a TTY the cells carry ANSI color codes, and
52
+ // padEnd would count those too — every colored column drifting a few chars wide.
53
+ const ANSI = /\x1b\[[0-9;]*m/g
54
+ const printed = (s: string) => s.replace(ANSI, "").length
55
+
56
+ /** Print rows as aligned columns (last column unpadded so colors don't drag trailing spaces). */
57
+ const table = (rows: string[][]) => {
58
+ const widths = rows[0].map((_, i) => Math.max(...rows.map(r => printed(r[i] ?? ""))))
59
+ for (const row of rows) {
60
+ const cells = row.map((cell, i) =>
61
+ i === row.length - 1 ? cell : cell + " ".repeat(Math.max(0, widths[i] - printed(cell))))
62
+ log(" " + cells.join(" "))
63
+ }
64
+ }
65
+
66
+ /** Comma-separated list flag. parseArgs keeps only the LAST of a repeated `--user`, so repeats
67
+ * would silently drop targets — commas are the form that can't lose one. */
68
+ const listFlag = (args: Args, ...keys: string[]): string[] => {
69
+ const raw = flagStr(args, ...keys)
70
+ if (!raw) return []
71
+ return [ ...new Set(raw.split(",").map(s => s.trim()).filter(Boolean)) ]
72
+ }
73
+
74
+ /* ---------------------------------------- send ---------------------------------------- */
75
+
76
+ /** `lecodes pn send --title … [--user u42] [--to pa_…]` — a real send through the relay,
77
+ * authenticated with your own token. Targets are the same ones a production server uses. */
78
+ const pnSend = async (args: Args) => {
79
+ const ctx = context()
80
+
81
+ const title = flagStr(args, "title", "t") ?? args._[1]
82
+ if (!title) throw new CliError('A title is required: lecodes pn send --title "Order shipped" --user u42')
83
+
84
+ const users = listFlag(args, "user", "users", "u")
85
+ const to = listFlag(args, "to", "address", "addresses")
86
+ if (users.length === 0 && to.length === 0) {
87
+ throw new CliError("Nothing to send to — pass --user <id> (from Push.register({ user })) or --to <pa_…>. `lecodes pn devices` lists both.")
88
+ }
89
+
90
+ const badgeRaw = flagStr(args, "badge")
91
+ const badge = badgeRaw === undefined ? undefined : Number(badgeRaw)
92
+ if (badge !== undefined && !Number.isInteger(badge)) throw new CliError(`--badge must be a whole number (got "${badgeRaw}")`)
93
+
94
+ const dataRaw = flagStr(args, "data")
95
+ let data: Record<string, unknown> | undefined
96
+ if (dataRaw !== undefined) {
97
+ try { data = JSON.parse(dataRaw) as Record<string, unknown> } catch { throw new CliError("--data must be a JSON object, e.g. --data '{\"orderId\":42}'") }
98
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new CliError("--data must be a JSON object, e.g. --data '{\"orderId\":42}'")
99
+ }
100
+
101
+ const payload: PushSendPayload = {
102
+ title,
103
+ body: flagStr(args, "body", "b"),
104
+ url: flagStr(args, "url"),
105
+ users: users.length ? users : undefined,
106
+ to: to.length ? to : undefined,
107
+ data,
108
+ badge,
109
+ }
110
+
111
+ const result = await sendPush(ctx.apiUrl, ctx.token, ctx.uuid, payload)
112
+
113
+ if (result.sent > 0) success(`Sent to ${result.sent} device${result.sent === 1 ? "" : "s"}.`)
114
+ if (result.failed.length === 0) {
115
+ if (result.sent === 0) {
116
+ warn("Matched 0 devices — nobody with that user id has registered. Run `lecodes pn devices` to see who has.")
117
+ }
118
+ return
119
+ }
120
+ warn(`${result.failed.length} failed:`)
121
+ for (const failure of result.failed) log(` ${c.dim(failure.address)} ${c.red(failure.reason)}`)
122
+ }
123
+
124
+ /* -------------------------------------- devices --------------------------------------- */
125
+
126
+ const deviceLabel = (device: PushDevice): string => {
127
+ const env = device.environment === "sandbox" ? c.yellow(" sandbox") : ""
128
+ return `${device.platform}${env}`
129
+ }
130
+
131
+ /** `lecodes pn devices [--user u42] [--forget pa_…]` — the registration ledger. Raw OS tokens
132
+ * never leave the backend, so this is address + who/where/when only. --forget drops a dead row
133
+ * (rotated token, wrong project) that would otherwise sit here forever and fail every send. */
134
+ const pnDevices = async (args: Args) => {
135
+ const ctx = context()
136
+ const user = flagStr(args, "user", "u")
137
+
138
+ const forget = flagStr(args, "forget")
139
+ if (forget) {
140
+ if (!flagBool(args, "yes", "y")) {
141
+ warn(`Forgetting ${c.bold(forget)} — sends to this address stop immediately. The device gets a NEW address on its next Push.register().`)
142
+ if (!await confirm("Forget it?")) return info("Cancelled.")
143
+ }
144
+ await forgetPushDevice(ctx.apiUrl, ctx.token, ctx.uuid, forget)
145
+ success(`Forgot ${c.bold(forget)}.`)
146
+ return
147
+ }
148
+
149
+ const { devices, truncated } = await listPushDevices(ctx.apiUrl, ctx.token, ctx.uuid, user)
150
+
151
+ if (devices.length === 0) {
152
+ info(user
153
+ ? `No devices registered for user ${c.bold(user)} in ${c.bold(ctx.name)}.`
154
+ : `No devices registered in ${c.bold(ctx.name)} yet.`)
155
+ info(c.dim("A device appears here after the app calls Push.register(). Note that `lecodes dev`"))
156
+ info(c.dim("bundles are unattributed — register() rejects there; open a PUBLISHED project via QR."))
157
+ return
158
+ }
159
+
160
+ info(`${devices.length} device${devices.length === 1 ? "" : "s"} in ${c.bold(ctx.name)}${user ? ` for user ${c.bold(user)}` : ""}:`)
161
+ table([
162
+ [c.dim("ADDRESS"), c.dim("PLATFORM"), c.dim("USER"), c.dim("HOST"), c.dim("LAST SEEN")],
163
+ ...devices.map(device => [
164
+ device.disabledAt ? c.dim(device.address) : device.address,
165
+ deviceLabel(device),
166
+ device.user ?? c.dim("—"),
167
+ device.hostApp,
168
+ device.disabledAt ? c.red("disabled") : ago(device.lastSeenAt),
169
+ ]),
170
+ ])
171
+ if (devices.some(d => d.disabledAt)) {
172
+ info(c.dim("Disabled = the provider reported a dead token; re-registering from the device revives it."))
173
+ }
174
+ if (truncated) warn("Showing the 200 most recently seen — there are more.")
175
+ }
176
+
177
+ /* ---------------------------------------- keys ---------------------------------------- */
178
+
179
+ const pnKeysList = async (ctx: Context) => {
180
+ const keys = await listPushKeys(ctx.apiUrl, ctx.token, ctx.uuid)
181
+ if (keys.length === 0) {
182
+ info(`No push keys in ${c.bold(ctx.name)}.`)
183
+ info(c.dim("You only need one to send from your OWN server — `lecodes pn send` uses your login."))
184
+ info(c.dim("Mint one with `lecodes pn keys new \"prod server\" --env`."))
185
+ return
186
+ }
187
+ info(`${keys.length} push key${keys.length === 1 ? "" : "s"} in ${c.bold(ctx.name)}:`)
188
+ table([
189
+ [c.dim("ID"), c.dim("NAME"), c.dim("KEY"), c.dim("LAST USED")],
190
+ ...keys.map(key => [String(key.id), key.name, `${key.prefix}${c.dim("…")}`, ago(key.lastUsedAt)]),
191
+ ])
192
+ }
193
+
194
+ /** Append `LECODES_PUSH_KEY=…` to <root>/.env and make sure git can't swallow it. Returns the
195
+ * paths touched. A key already in .env is REPLACED — rotating shouldn't leave two live lines. */
196
+ const writeEnv = (root: string, key: string): string[] => {
197
+ const touched: string[] = []
198
+ const envPath = join(root, ".env")
199
+ const line = `${ENV_VAR}=${key}`
200
+
201
+ const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""
202
+ if (new RegExp(`^${ENV_VAR}=`, "m").test(existing)) {
203
+ writeFileSync(envPath, existing.replace(new RegExp(`^${ENV_VAR}=.*$`, "m"), line))
204
+ } else {
205
+ const separator = !existing || existing.endsWith("\n") ? "" : "\n"
206
+ appendFileSync(envPath, `${separator}${line}\n`)
207
+ }
208
+ touched.push(".env")
209
+
210
+ // A push key in a committed .env is the one way this command could make things worse.
211
+ const ignorePath = join(root, ".gitignore")
212
+ const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : ""
213
+ if (!ignore.split("\n").some(l => l.trim() === ".env")) {
214
+ const separator = !ignore || ignore.endsWith("\n") ? "" : "\n"
215
+ appendFileSync(ignorePath, `${separator}.env\n`)
216
+ touched.push(".gitignore")
217
+ }
218
+ return touched
219
+ }
220
+
221
+ /** `lecodes pn keys new [name] [--env]` — the server mints it; this is the ONLY time the
222
+ * plaintext exists anywhere, so print it loudly (or bank it in .env with --env). */
223
+ const pnKeysNew = async (ctx: Context, args: Args) => {
224
+ const name = args._[2] ?? flagStr(args, "name")
225
+ const created = await createPushKey(ctx.apiUrl, ctx.token, ctx.uuid, name)
226
+
227
+ success(`Created push key ${c.bold(created.name)} (id ${created.id}).`)
228
+ log()
229
+ log(` ${c.bold(created.key)}`)
230
+ log()
231
+
232
+ if (flagBool(args, "env")) {
233
+ const touched = writeEnv(ctx.root, created.key)
234
+ success(`Wrote ${ENV_VAR} to ${touched.join(" + ")}.`)
235
+ } else {
236
+ warn("Copy it now — it is never shown again. Lose it and you mint a new one.")
237
+ info(c.dim(` Tip: \`lecodes pn keys new --env\` writes it straight into .env as ${ENV_VAR}.`))
238
+ }
239
+
240
+ log()
241
+ info(c.dim("Send from your server with:"))
242
+ log(c.dim(` curl -X POST ${ctx.apiUrl}/api/projects/${ctx.uuid}/push/send \\`))
243
+ log(c.dim(` -H "Authorization: Bearer $${ENV_VAR}" -H "Content-Type: application/json" \\`))
244
+ log(c.dim(` -d '{"title":"Order shipped","users":["u42"]}'`))
245
+ }
246
+
247
+ /** `lecodes pn keys revoke <id>` — deletion, effective on the next request. Confirmed unless -y. */
248
+ const pnKeysRevoke = async (ctx: Context, args: Args) => {
249
+ const raw = args._[2]
250
+ if (!raw) throw new CliError("Which key? `lecodes pn keys` lists their ids, then `lecodes pn keys revoke <id>`.")
251
+ const id = Number(raw)
252
+ if (!Number.isInteger(id)) throw new CliError(`"${raw}" is not a key id — run \`lecodes pn keys\` to see them.`)
253
+
254
+ const keys = await listPushKeys(ctx.apiUrl, ctx.token, ctx.uuid)
255
+ const key = keys.find(k => k.id === id)
256
+ if (!key) throw new CliError(`No push key with id ${id} in ${ctx.name}.`)
257
+
258
+ if (!flagBool(args, "yes", "y")) {
259
+ warn(`Revoking ${c.bold(key.name)} (${key.prefix}…) — any server still using it stops sending immediately.`)
260
+ if (!await confirm("Revoke it?")) return info("Cancelled.")
261
+ }
262
+ await revokePushKey(ctx.apiUrl, ctx.token, ctx.uuid, id)
263
+ success(`Revoked push key ${c.bold(key.name)}.`)
264
+ }
265
+
266
+ const pnKeys = async (args: Args) => {
267
+ const ctx = context()
268
+ const sub = args._[1]
269
+ if (!sub || sub === "list") return pnKeysList(ctx)
270
+ if (sub === "new" || sub === "create") return pnKeysNew(ctx, args)
271
+ if (sub === "revoke" || sub === "delete" || sub === "rm") return pnKeysRevoke(ctx, args)
272
+ throw new CliError(`Unknown keys command "${sub}" — use: lecodes pn keys [list | new [name] | revoke <id>]`)
273
+ }
274
+
275
+ /* --------------------------------------- entry ---------------------------------------- */
276
+
277
+ const PN_HELP = `${c.bold("lecodes pn")} — push notifications for this project.
278
+
279
+ ${c.bold("Commands:")}
280
+ pn send --title <s> Send a notification (uses your login — no key needed)
281
+ --body <s> second line
282
+ --url <s> deep link (also arrives as app.launchUrl)
283
+ --user <ids> target Push.register({ user }) ids, comma-separated
284
+ --to <pa_…> target addresses, comma-separated
285
+ --badge <n> app icon badge
286
+ --data <json> your JSON payload (≤ 2 KB)
287
+ pn devices List the devices that registered for this project
288
+ --user <id> only that user's devices
289
+ --forget <pa_…> drop a dead registration (-y to skip confirm)
290
+ pn keys List this project's push keys
291
+ pn keys new [name] Mint one for YOUR server (owner only; shown once)
292
+ --env write it to .env as ${ENV_VAR} (+ gitignore)
293
+ pn keys revoke <id> Revoke a key (-y to skip the confirmation)
294
+
295
+ ${c.dim("A push key is generated BY the server — you can't choose or recover one. It only sends")}
296
+ ${c.dim("for this project, which is why deploying it beats deploying a personal access token.")}`
297
+
298
+ export const pn = async (args: Args) => {
299
+ const sub = args._[0]
300
+ if (!sub || sub === "help") return log(PN_HELP)
301
+ if (sub === "send") return pnSend(args)
302
+ if (sub === "devices" || sub === "device") return pnDevices(args)
303
+ if (sub === "keys" || sub === "key") return pnKeys(args)
304
+ throw new CliError(`Unknown pn command "${sub}" — use: lecodes pn <send | devices | keys>. Run \`lecodes pn\` for the full list.`)
305
+ }
@@ -0,0 +1,130 @@
1
+ /* Templates `lecodes init` / `lecodes create` scaffold into a new APP project (the design folder
2
+ * has its own set — designTemplates.ts). CLAUDE.md is the AI-assistant counterpart of README.md:
3
+ * it teaches the headless feedback loop (render / --script / test) and the conventions that make
4
+ * an app testable, so an assistant opening the project drives the real app instead of guessing.
5
+ * Facts here mirror lecodes-renderer's scenario engine — update both when the format changes. */
6
+
7
+ export const appClaudeMd = (name: string) => `# ${name} — LeCodes app
8
+
9
+ Guidance for AI assistants working in this folder. Human docs: README.md. The whole SDK is
10
+ available as globals (\`UIScreen\`, \`UIText\`, \`Scene2D\`, …) — no SDK imports. \`main.ts\` is
11
+ the entry point; split code into other \`.ts\` files and import them (relative imports only) —
12
+ a file no other file imports is treated as an entry candidate, so keep everything reachable
13
+ from \`main.ts\`.
14
+
15
+ ## Feedback loop — run the app headless instead of guessing
16
+
17
+ \`\`\`
18
+ lecodes render # semantic JSON of the launched UI (structure, text, names, values)
19
+ lecodes render --png shot.png # pixels (needs @napi-rs/canvas installed)
20
+ lecodes render --png card.png --clip card # ONE component/region: a node name or x,y,w,h
21
+ lecodes render src/screens/cart.ts # ONE screen module, without booting the app: any file that
22
+ # \`export default\`s a UIScreen (or a (state) => UIScreen —
23
+ # --state <name> picks the branch). Same flags as above.
24
+ lecodes render --desktop --png shot.png # NATIVE render: real Filament 3D + UI + 2D composited —
25
+ # use this to SEE 3D scenes / procedural geometry (headless
26
+ # renders record 3D as JSON — simulated, not drawn). Windows/Linux; downloads
27
+ # the renderer on first use. --frames <n> for slow loads.
28
+ lecodes render --script <file> # drive the app through a scenario (format below)
29
+ lecodes render --desktop --view iso --png iso.png # 3D: inspection camera fitted to the WHOLE
30
+ # scene (iso|top|front|side|back [--fov N]) — or --camera x,y,z
31
+ # --look-at x,y,z; the app's camera is untouched. In scripts:
32
+ # { "view": "iso", "screenshot": "iso.png" } then { "view": "off" }
33
+ lecodes render --desktop --script <file> # the same scenario on the NATIVE host (3D/Jolt, real
34
+ # GPU): coordinates only ({ x, y }), expect.log checked,
35
+ # text/node/screen expects skipped; deterministic 16 ms frames
36
+ lecodes test # all tests/*.flow.json + design-derived flows; run before "done"
37
+ lecodes test --desktop # the file flows on the NATIVE host (3D/Jolt/real GPU; coordinate
38
+ # flows only — design flows stay headless)
39
+ \`\`\`
40
+
41
+ Iterate with \`--script\` scenarios and keep the useful ones as \`tests/<name>.flow.json\` — they
42
+ are the regression suite. A failing step dumps the failing frame (JSON + PNG) under
43
+ \`tests/.artifacts/\` so a red step explains itself.
44
+
45
+ ## Scenario format (tests/*.flow.json and --script files)
46
+
47
+ One action per step (\`tap\`, \`type\`+\`text\`, \`fill\`, \`scroll\` [+\`by\`], \`wait\`, and for
48
+ games: \`frames\` (advance N fixed 1/60 steps, no sleeping), \`drag\`+\`to\` [+\`ms\`], \`hold\`+\`ms\`,
49
+ \`key\`+\`ms\` / \`keyDown\` / \`keyUp\` (KeyboardEvent.code: "ArrowRight", "KeyW", "Space" — mouse
50
+ buttons are keys too: "MouseLeft"/"MouseRight"), \`look: [dx, dy]\` [+\`ms\`] (mouse motion for
51
+ \`Input.mouse.delta\`), \`mouseTo: [x, y]\` (cursor position), \`wheel: [dx, dy]\`, gamepad:
52
+ \`stick: { left: [x, y], right: [x, y], triggers: [l, r] }\` (values persist; \`index\` = pad),
53
+ \`gamepad: "connect" | "disconnect"\`, pad buttons as keys: \`keyDown: "GamepadSouth"\`), then an
54
+ optional \`expect\` (\`screen\`, \`node\`/\`notNode\`, \`text\`/\`notText\`,
55
+ \`value: { target, equals }\`, \`log\` — a \`console.log\` line contains it), then outputs
56
+ (\`screenshot\` [+\`clip\`: a node name or \`{ x, y, width, height }\`], \`json\`). Targets resolve by
57
+ \`name\` → exact visible text → substring → input placeholder; a tap/drag on empty space reaches
58
+ the 2D scene (sprite \`click\`/\`touchstart\`, \`ev.track()\`). Envelope keys: \`name\`,
59
+ \`width\`/\`height\`, \`settle\`, \`fixtures\`, \`steps\`.
60
+
61
+ \`\`\`json
62
+ { "steps": [
63
+ { "expect": { "screen": "home", "text": "Taps: 0" } },
64
+ { "tap": "tap", "expect": { "text": "Taps: 1" }, "screenshot": "after.png" },
65
+ { "key": "ArrowRight", "ms": 500, "expect": { "log": "hero x=" } },
66
+ { "frames": 60, "screenshot": "hud.png", "clip": "hud" }
67
+ ] }
68
+ \`\`\`
69
+
70
+ Game time is deterministic headless: \`setLoop\`, 2D physics/animation and aspect \`update(dt)\`
71
+ advance in fixed 1/60 steps driven by \`wait\`/\`frames\`/\`drag\`/\`key\` — never by a wall clock —
72
+ so a flow that passes once passes every time. \`Input.key(code)\` reads the keys a flow holds and
73
+ \`Input.on('keydown' | 'keyup', e => e.code)\` fires for each \`key\`/\`keyDown\`/\`keyUp\` step
74
+ (events land before the next loop tick); \`stick\` values are what \`Input.gamepad(0).axis()\` reads.
75
+ Headless draws UI + the 2D scene (sprites, tilemaps, Box2D); 3D is captured as JSON with REAL Jolt
76
+ physics (bodies fall, characters collide, triggers fire, ragdolls drop — a character with no ground
77
+ under it falls, like on device) — use \`--desktop\` for 3D pixels. \`console.log\` + \`expect.log\` is the assertion
78
+ channel for game state that has no UI.
79
+
80
+ ## Make the app testable
81
+
82
+ - Give interactive elements a \`name\`: \`UIButton(...).style({ name: "login" })\`.
83
+ - Name every screen's ROOT node with the screen's id — \`expect.screen\` reads it.
84
+ - If the project has a \`design/\` folder, keep the design's screen ids and activator \`name\`s
85
+ in the app code: \`lecodes test\` then checks the built app against the designed navigation
86
+ automatically (conventions in design/CLAUDE.md).
87
+ - Backend-gated screens: canned responses in \`tests/fixtures.json\` (URL pattern →
88
+ \`{ status?, json?, text? }\`; \`*\` wildcards full-match, plain strings substring-match, first
89
+ match wins). \`lecodes test\` auto-loads it for design-derived flows; a scenario file opts in
90
+ with \`"fixtures": "fixtures.json"\` in its envelope.
91
+ - \`fill\` fills every empty input — use it before tapping a disabled-until-filled submit.
92
+
93
+ ## SDK documentation
94
+
95
+ \`.lecodes/types/\` is the exact API surface (signatures only; generated and gitignored — run
96
+ \`lecodes update\` if it's missing, never commit \`.lecodes/\`). For semantics and examples,
97
+ fetch the docs — they are served as raw markdown for AI tools:
98
+
99
+ - https://le.codes/llms.txt — the docs map; fetch it first to find the right page
100
+ - https://le.codes/en/docs/api/<area>/<page>.md — one reference page as raw markdown
101
+ (e.g. \`ui/styling.md\`, \`2d/scene2d.md\`, \`3d/scene.md\`, \`runtime/storage.md\`)
102
+ - https://le.codes/en/docs/guide/<page>.md — guide pages (project anatomy, CLI, tracks)
103
+ - https://le.codes/llms-full.txt — everything in one file (large; prefer per-page)
104
+
105
+ Always fetch the \`.md\` URLs — the HTML routes are a JS app and return an empty shell.
106
+
107
+ ## Other commands
108
+
109
+ \`lecodes dev\` (run on a device with hot reload + QR), \`lecodes compile -o app.js\`
110
+ (single-file bundle), \`lecodes design\` (screen-design board — the living spec).
111
+ `
112
+
113
+ /** `.claude/settings.json` scaffolded next to CLAUDE.md: pre-approves doc fetches from
114
+ * le.codes so the assistant can follow the "SDK documentation" pointers without a
115
+ * per-domain permission prompt on first use. */
116
+ export const CLAUDE_SETTINGS = JSON.stringify({
117
+ permissions: { allow: ["WebFetch(domain:le.codes)"] },
118
+ }, null, 2) + "\n"
119
+
120
+ /** A flow test that passes against init's starter counter app — proves the loop works out of the
121
+ * box and gives an in-project example of the format (the button/screen carry matching names). */
122
+ export const STARTER_FLOW = `{
123
+ "name": "counter",
124
+ "steps": [
125
+ { "expect": { "screen": "home", "text": "Taps: 0" } },
126
+ { "tap": "tap", "expect": { "text": "Taps: 1" } },
127
+ { "tap": "tap", "expect": { "text": "Taps: 2" } }
128
+ ]
129
+ }
130
+ `