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,11 @@
1
+ /* File templates `lecodes design init` scaffolds into the design folder. The strings live in
2
+ * lecodes-design (src/templates.ts, in the lecodes-tools repo) — the single source shared with the
3
+ * platform editor's "Create design" button. Imported by a RELATIVE path through the package's
4
+ * node_modules link (not as `lecodes-design/templates`) on purpose: the CLI build externalizes the
5
+ * `lecodes-design` package (it's an optional runtime install for `design serve`), but `init` must
6
+ * work without it — a relative import gets bundled into the binary like any local module. The link
7
+ * is the `link:lecodes-design` dev dependency (see the root `bun run link`). */
8
+
9
+ export {
10
+ META_JSON, META_JSON_DESKTOP, SPEC_MD, TOKENS_TS, TABS_TS, HOME_SCREEN_TS, CLAUDE_MD, DESIGN_MACROS_DTS, README_HINT,
11
+ } from "../../node_modules/lecodes-design/src/templates"
@@ -0,0 +1,127 @@
1
+ import { basename } from "node:path"
2
+ import { CliError, c, flagBool, flagStr, log, note, type Args } from "../util"
3
+ import { findProjectRoot } from "../manifest"
4
+ import { buildBundle } from "./compile"
5
+ import { assertRendererSupportsBundle, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
6
+ import { buildDesktop } from "./appDesktop"
7
+ import {
8
+ DESKTOP_RENDERER_VERSION,
9
+ fetchDesktopManifest,
10
+ findMacHostExe,
11
+ installedDesktopVersions,
12
+ runDesktopApp,
13
+ selectedDesktopVersion,
14
+ semverNewer,
15
+ updateDesktop,
16
+ } from "../desktopRenderer"
17
+
18
+ /*
19
+ * `lecodes desktop` — everything about the desktop target in one place.
20
+ * (no args) status: the version renders use, what's cached, what's published
21
+ * run compile the current project and open it in a real desktop window
22
+ * build stage a portable app folder (the same command as `lecodes app build desktop`,
23
+ * which stays for symmetry with the ios/android shells)
24
+ * update move the host to the newest published release (downloads it if needed)
25
+ * The binary cache lives in ~/.lecodes/renderer/; LECODES_DESKTOP_EXE overrides everything.
26
+ */
27
+
28
+ /** `--scale 2` / `--scale fit` / app.json desktop.window.scale → the host's scale knob
29
+ * ("system" and absent mean: let the host decide from the OS display scale). */
30
+ export const resolveScale = (flag: string | undefined, cfg: number | "fit" | "system" | undefined): number | "fit" | undefined => {
31
+ const raw = flag ?? cfg
32
+ if (raw === undefined || raw === "system") return undefined
33
+ if (raw === "fit") return "fit"
34
+ const n = Number(raw)
35
+ if (!Number.isFinite(n) || n < 0.5 || n > 4) {
36
+ throw new CliError(`scale must be a number 0.5..4, "fit" or "system" (got "${raw}")`)
37
+ }
38
+ return n
39
+ }
40
+
41
+ export const resolveRenderScale = (flag: string | undefined, cfg: number | undefined): number | undefined => {
42
+ const raw = flag ?? cfg
43
+ if (raw === undefined) return undefined
44
+ const n = Number(raw)
45
+ if (!Number.isFinite(n) || n < 0.25 || n > 1) throw new CliError(`render-scale must be a number 0.25..1 (got "${raw}")`)
46
+ return n
47
+ }
48
+
49
+ const run = async (args: Args) => {
50
+ // Window defaults come from app.json's desktop block (flags win); standalone projects
51
+ // without an app.json keep the host's built-in 1280×720.
52
+ let root: string
53
+ try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() }
54
+ const appCfg = readAppConfigAt(root)
55
+ const win = appCfg?.desktop?.window
56
+ // ONE renderer decision per run: it picks the host binary AND which .filamat gets staged beside
57
+ // the bundle. Reading the config twice is how a Vulkan exe ends up next to an OpenGL shader.
58
+ const renderer = resolveDesktopRenderer(root, args)
59
+ const { js, resources } = await buildBundle(args, undefined, { localAssets: true, desktopRenderer: renderer })
60
+ assertRendererSupportsBundle(renderer, js)
61
+ // Native plugins: the project's plugins/ dir + declared plugin libraries, scanned by the host.
62
+ const { dirs: pluginDirs, files: pluginFiles } = desktopPluginLibraries(root)
63
+ if (pluginFiles.length > 0) note(`Native plugins: ${pluginFiles.map((f) => basename(f)).join(", ")}`)
64
+ note(`Launching LeCodes Desktop${renderer === "vulkan" ? " (Vulkan)" : ""}… (close the window to return)`)
65
+ await runDesktopApp(js, resources, {
66
+ renderer,
67
+ pluginDirs,
68
+ width: Number(flagStr(args, "width")) || win?.width,
69
+ height: Number(flagStr(args, "height")) || win?.height,
70
+ minWidth: win?.minWidth,
71
+ minHeight: win?.minHeight,
72
+ resizable: win?.resizable,
73
+ title: flagStr(args, "title") ?? win?.title ?? appCfg?.name,
74
+ // --fullscreen forces it on; --windowed forces it off (over app.json); else the config decides.
75
+ fullscreen: flagBool(args, "fullscreen") ? true : flagBool(args, "windowed") ? false : win?.fullscreen,
76
+ scale: resolveScale(flagStr(args, "scale"), win?.scale),
77
+ // 3D render resolution: --render-scale wins over app.json desktop.renderScale; the cap comes
78
+ // from app.json only. Both are desktop-level (the machine/app), not window-level.
79
+ renderScale: resolveRenderScale(flagStr(args, "render-scale"), appCfg?.desktop?.renderScale),
80
+ maxRenderSize: appCfg?.desktop?.maxRenderSize,
81
+ // --fps: the host's own corner counter. A flag and not app.json on purpose — it is something you
82
+ // switch on while looking at a frame, not a property of the app.
83
+ fps: flagBool(args, "fps"),
84
+ // Multiplayer (docs/multiplayer-plan.md): --server [--port N] [--max-clients N] starts the host as a
85
+ // dedicated server; --connect ip:port joins one. Both only set Net.launch — the app calls Net.listen/connect.
86
+ net: flagBool(args, "server")
87
+ ? { role: "server", port: Number(flagStr(args, "port")) || undefined, maxClients: Number(flagStr(args, "max-clients")) || undefined }
88
+ : flagStr(args, "connect")
89
+ ? { role: "client", address: flagStr(args, "connect") }
90
+ : undefined,
91
+ })
92
+ }
93
+
94
+ const status = async () => {
95
+ const selected = selectedDesktopVersion()
96
+ const installed = installedDesktopVersions()
97
+ log(`renders with ${selected}${installed.includes(selected) ? "" : c.dim(" (not downloaded yet — fetched on first render)")}`)
98
+ log(`CLI minimum ${DESKTOP_RENDERER_VERSION}`)
99
+ log(`downloaded ${installed.length > 0 ? installed.join(", ") : c.dim("none")} ${c.dim("(~/.lecodes/renderer)")}`)
100
+ if (process.env.LECODES_DESKTOP_EXE) log(`override LECODES_DESKTOP_EXE=${process.env.LECODES_DESKTOP_EXE}`)
101
+ if (process.platform === "darwin") {
102
+ // The cache is what runs/builds use; a hand-installed app only steps in when the manifest is unreachable.
103
+ const installed = findMacHostExe()
104
+ log(`installed ${installed ?? c.dim("none")} ${c.dim("(offline fallback only)")}`)
105
+ }
106
+ try {
107
+ const { latest } = await fetchDesktopManifest()
108
+ const hint = semverNewer(selected, latest) > 0
109
+ ? c.yellow("(newer — run `lecodes desktop update`)")
110
+ : latest === selected
111
+ ? c.dim("(up to date)")
112
+ : c.dim("(older than the CLI's minimum — this desktop release isn't published yet)")
113
+ log(`published ${latest} ${hint}`)
114
+ } catch {
115
+ log(`published ${c.dim("unknown (couldn't reach the release manifest)")}`)
116
+ }
117
+ }
118
+
119
+ export const desktop = async (args: Args) => {
120
+ const sub = args._[0]
121
+ if (sub === undefined) return status()
122
+ if (sub === "run") return run(args)
123
+ // `buildDesktop` reads only flags, never positionals, so it takes both spellings unchanged.
124
+ if (sub === "build") return buildDesktop(args)
125
+ if (sub === "update") return updateDesktop()
126
+ throw new CliError(`Unknown subcommand "desktop ${sub}". Use \`lecodes desktop\` (status), \`lecodes desktop run\`, \`lecodes desktop build\` or \`lecodes desktop update\`.`)
127
+ }
@@ -0,0 +1,236 @@
1
+ import { existsSync, mkdirSync, readFileSync, watch, writeFileSync, type FSWatcher } from "node:fs"
2
+ import { randomBytes } from "node:crypto"
3
+ import { networkInterfaces } from "node:os"
4
+ import { basename, join } from "node:path"
5
+ import { findProjectRoot, readManifest, LECODES_DIR, type Manifest } from "../manifest"
6
+ import { loadIgnore } from "../ignore"
7
+ import { startDevServer } from "../dev/devServer"
8
+ import { resolveWebRunnerDir } from "../dev/webRunner"
9
+ import { openBrowser } from "../browserAuth"
10
+ import { runDesktopDev } from "../desktopRenderer"
11
+ import { readAppConfigAt } from "./appShared"
12
+ import { resolveScale } from "./desktop"
13
+ import { CliError, c, flagBool, flagStr, log, note, toKey, warnErr, type Args } from "../util"
14
+
15
+ /*
16
+ * `lecodes dev` — run the project on a device straight from this machine, with hot reload.
17
+ *
18
+ * Starts a LAN dev server (compile + assets + reload channel), prints a QR code, and watches the
19
+ * working tree. The device opens the QR URL like any project URL; every save recompiles and
20
+ * hot-swaps the running app (`_creatorUtils.run` — fresh context, rollback on error). Device
21
+ * console output and errors stream back to this terminal. Fully local: no login, no le.codes
22
+ * backend. Custom shaders compile locally too (matc, downloaded on first use — matcTool.ts);
23
+ * only when matc can't be fetched do they fall back to server-compiled artifacts (push once).
24
+ *
25
+ * `--web` opens the same project in the browser: the dev server also serves the Web Lite runner
26
+ * (viewer-lite's dist-embed, vendored as runtime/web) under /web/ — CSS-flexbox UI + OGL 3D + the
27
+ * web twins of the camera/map plugins, no wasm. Same bundle and hot reload; browser console
28
+ * output streams back here like a device's. The URL is printed whenever the runner is available.
29
+ *
30
+ * Works inside a cloned project or standalone in any folder with an entry .ts file.
31
+ * The desktop host speaks websocket since 1.0.0 (host_ws); `?t=poll` remains the fallback
32
+ * for older desktop builds (or any host without a websocket bridge).
33
+ */
34
+
35
+ const DEFAULT_PORT = 4499
36
+ const DEBOUNCE_MS = 120
37
+
38
+ // Never trigger a recompile (mirrors scanLocal's skip lists + our own artifacts).
39
+ const WATCH_SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"])
40
+ const WATCH_SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"])
41
+
42
+ /*
43
+ * The origin a phone on the same Wi-Fi can reach, best first.
44
+ *
45
+ * `net.internal` is NOT enough on its own: in Node it only means loopback, so a WSL/Docker/Hyper-V
46
+ * bridge or a VPN tap counts as a normal external interface and wins whenever the OS enumerates it
47
+ * first. Those are host-only — the device dials them and hangs until the socket times out, which is
48
+ * a confusing way to learn your dev URL was never reachable. So rank instead of taking the first:
49
+ * de-prioritise virtual-looking adapter names, then Docker/WSL's 172.16–31 block, then plain
50
+ * 169.254 link-local. Ties keep OS order. Anything genuinely ambiguous (Ethernet AND Wi-Fi on
51
+ * different subnets, as here) is unknowable from this side — hence `lanCandidates` in the printout
52
+ * and `--host`.
53
+ */
54
+ const VIRTUAL_IFACE = /wsl|docker|hyper-v|vethernet|virtualbox|vmware|tap|tun|zerotier|tailscale|loopback/i
55
+
56
+ const lanCandidates = (): { address: string, iface: string }[] => {
57
+ const nets = networkInterfaces()
58
+ const out: { address: string, iface: string, rank: number }[] = []
59
+ for (const iface of Object.keys(nets)) {
60
+ for (const net of nets[iface] ?? []) {
61
+ if (net.family !== "IPv4" || net.internal) continue
62
+ const a = net.address
63
+ let rank = 0
64
+ if (VIRTUAL_IFACE.test(iface)) rank += 4
65
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(a)) rank += 2 // Docker/WSL default pools live here
66
+ if (a.startsWith("169.254.")) rank += 8 // link-local: no DHCP, rarely routable
67
+ out.push({ address: a, iface, rank })
68
+ }
69
+ }
70
+ return out.sort((x, y) => x.rank - y.rank).map(({ address, iface }) => ({ address, iface }))
71
+ }
72
+
73
+ /** The per-project dev token, persisted so the QR/URL stays stable across runs. */
74
+ const loadDevToken = (root: string): string => {
75
+ const path = join(root, LECODES_DIR, "dev.json")
76
+ try {
77
+ const token = (JSON.parse(readFileSync(path, "utf8")) as { token?: string }).token
78
+ if (token && /^[a-z0-9]{8,}$/.test(token)) return token
79
+ } catch { /* first run */ }
80
+ const token = randomBytes(6).toString("hex")
81
+ mkdirSync(join(root, LECODES_DIR), { recursive: true })
82
+ writeFileSync(path, JSON.stringify({ token }, null, 2) + "\n")
83
+ return token
84
+ }
85
+
86
+ /** Print a scannable QR for the URL (qrcode-terminal is bundled; URL-only if it ever fails). */
87
+ const printQr = async (url: string) => {
88
+ try {
89
+ const qrcode = (await import("qrcode-terminal")).default
90
+ await new Promise<void>((resolve) => qrcode.generate(url, { small: true }, (out: string) => { log(out); resolve() }))
91
+ } catch {
92
+ note("(couldn't render a QR code — open the URL below on the device)")
93
+ }
94
+ }
95
+
96
+ export const dev = async (args: Args) => {
97
+ // Inside a cloned project the server runs at its root; anywhere else it runs standalone against
98
+ // the current directory (no login / manifest needed) — same rule as `lecodes design`.
99
+ let root: string
100
+ let manifest: Manifest | null
101
+ try {
102
+ root = findProjectRoot(process.cwd())
103
+ manifest = readManifest(root)
104
+ } catch {
105
+ root = process.cwd()
106
+ manifest = null
107
+ }
108
+ const name = manifest?.name ?? basename(root)
109
+
110
+ // Bun's node:http can't complete a websocket upgrade (writes to the upgraded socket are dropped),
111
+ // so the reload push would silently never reach devices. Node is the shipped runtime.
112
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
113
+ warnErr("Running under Bun: the websocket reload channel doesn't work here — run `lecodes dev` with Node (or use ?t=poll on the device URL).")
114
+ }
115
+
116
+ const port = Number(flagStr(args, "port")) || DEFAULT_PORT
117
+ const candidates = lanCandidates()
118
+ const host = flagStr(args, "host") ?? candidates[0]?.address
119
+ if (!host) throw new CliError("No LAN address found — pass one with --host <ip>.")
120
+ // A machine with several NICs can only be disambiguated by the human: name the runners-up so a
121
+ // device that can't reach the printed URL is one --host away from working, not a debugging session.
122
+ const alternatives = candidates.filter((c) => c.address !== host)
123
+ const origin = `http://${host}:${port}`
124
+ const token = loadDevToken(root)
125
+
126
+ const webDir = resolveWebRunnerDir()
127
+ if (flagBool(args, "web") && !webDir) {
128
+ throw new CliError("No Web Lite runner found for --web: this CLI has no runtime/web (a monorepo checkout needs `bun run build:embed` in packages/viewer-lite).")
129
+ }
130
+ const server = await startDevServer({
131
+ root, manifest, name, origin, port, token,
132
+ entryOverride: flagStr(args, "entry"),
133
+ webDir,
134
+ log: note,
135
+ }).catch((e: unknown) => {
136
+ const msg = e instanceof Error ? e.message : String(e)
137
+ throw new CliError(msg.includes("EADDRINUSE") ? `Port ${port} is already in use — pass --port <n>.` : msg)
138
+ })
139
+
140
+ note("Compiling…")
141
+ const first = await server.recompile()
142
+ if (first.ok) {
143
+ for (const w of first.warnings) warnErr(w)
144
+ } else {
145
+ warnErr(`Compile failed — fix it and save; devices connect once it compiles:\n${first.error}`)
146
+ }
147
+
148
+ // ---- watcher: debounced whole-project recompile; a successful compile broadcasts the reload ----
149
+ const ignore = loadIgnore(root)
150
+ const shouldRecompile = (file: string | null): boolean => {
151
+ if (file === null) return true // event without a filename — assume project content
152
+ const parts = file.replace(/\\/g, "/").split("/")
153
+ if (parts.some((p) => WATCH_SKIP_DIRS.has(p))) return false
154
+ if (WATCH_SKIP_FILES.has(parts[parts.length - 1])) return false
155
+ try {
156
+ const key = toKey(file)
157
+ if (ignore(key, false)) return false
158
+ } catch { return false }
159
+ return true
160
+ }
161
+
162
+ const debug = process.env.LECODES_DEV_DEBUG === "1"
163
+ let timer: NodeJS.Timeout | null = null
164
+ const onChange = (file: string | null) => {
165
+ if (debug) note(`[watch] event: ${file ?? "(null)"} → ${shouldRecompile(file) ? "recompile" : "skip"}`)
166
+ if (!shouldRecompile(file)) return
167
+ if (timer) clearTimeout(timer)
168
+ timer = setTimeout(async () => {
169
+ timer = null
170
+ const result = await server.recompile()
171
+ if (!result.ok) {
172
+ warnErr(`Compile failed (devices keep the last good version):\n${result.error}`)
173
+ } else if (result.changed) {
174
+ const devices = server.clientCount()
175
+ note(`✓ v${result.v} → ${devices} device${devices === 1 ? "" : "s"}`)
176
+ for (const w of result.warnings) warnErr(w)
177
+ }
178
+ }, DEBOUNCE_MS)
179
+ }
180
+
181
+ let watcher: FSWatcher | null = null
182
+ try {
183
+ watcher = watch(root, { recursive: true }, (_type, file) => onChange(file))
184
+ } catch {
185
+ warnErr("fs.watch(recursive) unavailable on this platform — hot reload disabled (restart to pick up changes).")
186
+ }
187
+ watcher?.on("error", (e) => warnErr(`Watcher error — hot reload may have stopped (restart lecodes dev): ${e.message}`))
188
+
189
+ // ---- banner ------------------------------------------------------------------------------------
190
+ log("")
191
+ log(`${c.bold("LeCodes Dev")} — ${name}`)
192
+ log("")
193
+ if (!flagBool(args, "no-qr")) await printQr(server.url)
194
+ log(`Scan the QR with the LeCodes app, or open: ${c.bold(server.url)}`)
195
+ // The browser runner: localhost is a secure context (camera works there), the LAN address is
196
+ // what a phone's browser can reach.
197
+ const webLocal = server.webUrl ? server.webUrl.replace(`http://${host}:`, "http://localhost:") : null
198
+ if (server.webUrl && webLocal) log(`In the browser (Web Lite): ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
199
+ note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
200
+ if (alternatives.length > 0) {
201
+ note(`Device can't reach it? This machine also has ${alternatives.map((a) => `${a.address} (${a.iface})`).join(", ")} — retry with --host <ip>.`)
202
+ }
203
+ if (existsSync(join(root, LECODES_DIR, "manifest.json"))) note(`Token stored in ${LECODES_DIR}/dev.json (delete it to rotate the URL).`)
204
+ log("")
205
+ log(`Watching ${root} — save a file to hot-reload. Ctrl+C to stop.`)
206
+
207
+ // --web: open the browser runner (the server keeps serving it either way).
208
+ if (flagBool(args, "web") && webLocal) {
209
+ note("Opening the browser…")
210
+ openBrowser(webLocal)
211
+ }
212
+
213
+ // --desktop: open the dev URL in the native host too (same server, same hot reload — the host
214
+ // speaks the websocket channel). Window config comes from app.json's desktop block. The window
215
+ // closing does NOT stop the server: phones stay connected, and re-running with --desktop is the
216
+ // reopen. Errors (host not downloadable, unsupported OS) degrade to a warning — the QR path
217
+ // must keep working.
218
+ if (flagBool(args, "desktop")) {
219
+ const appCfg = readAppConfigAt(root)
220
+ const win = appCfg?.desktop?.window
221
+ note("Opening LeCodes Desktop against the dev server…")
222
+ // scale IS forwarded (density changes how the app looks — dev should match production);
223
+ // fullscreen deliberately is NOT (a fullscreen dev window hides the terminal).
224
+ void runDesktopDev(server.url, {
225
+ width: win?.width, height: win?.height,
226
+ minWidth: win?.minWidth, minHeight: win?.minHeight,
227
+ resizable: win?.resizable,
228
+ title: win?.title ?? appCfg?.name ?? name,
229
+ scale: resolveScale(undefined, win?.scale),
230
+ fps: flagBool(args, "fps"),
231
+ }).then(
232
+ () => note("Desktop window closed — the dev server keeps running (Ctrl+C to stop)."),
233
+ (e: unknown) => warnErr(`Desktop host: ${e instanceof Error ? e.message : String(e)}`),
234
+ )
235
+ }
236
+ }
@@ -0,0 +1,59 @@
1
+ import { getProject } from "../api"
2
+ import { loadConfig, requireApiUrl, requireToken } from "../config"
3
+ import { findProjectRoot, readManifest } from "../manifest"
4
+ import { scanProject } from "../localFiles"
5
+ import { computeServerDiff, type ServerDiffEntry } from "../serverDiff"
6
+ import { unifiedDiff } from "../textDiff"
7
+ import { c, info, log, flagBool, toKey, type Args } from "../util"
8
+
9
+ /*
10
+ * `lecodes diff [path…]` — compare the local working tree against the project's CURRENT server state
11
+ * and print a unified diff. The "+" side is local, the "-" side is the server, so it answers "what
12
+ * would my push change?" — including any edits made on the server (e.g. in the web editor) since you
13
+ * cloned. The headline use is conflict resolution before `push --force`.
14
+ *
15
+ * --name-only list changed paths + status instead of full diffs
16
+ * [path…] limit to these files / folders
17
+ */
18
+ export const diff = async (args: Args) => {
19
+ const root = findProjectRoot(process.cwd())
20
+ const manifest = readManifest(root)
21
+ const config = loadConfig()
22
+ const apiUrl = requireApiUrl(config)
23
+ const token = requireToken(config)
24
+
25
+ info(c.dim("Comparing with the server…"))
26
+ const project = await getProject(apiUrl, token, manifest.uuid)
27
+ const scan = scanProject(root, manifest)
28
+ let entries = computeServerDiff(project, scan)
29
+
30
+ // Optional path filter (a file, or a folder prefix).
31
+ if (args._.length > 0) {
32
+ const keys = args._.map(toKey)
33
+ entries = entries.filter(e => keys.some(k => e.path === k || e.path.startsWith(k + "/")))
34
+ }
35
+
36
+ if (entries.length === 0) {
37
+ info(c.dim("No differences between local and server."))
38
+ return
39
+ }
40
+
41
+ if (flagBool(args, "name-only")) {
42
+ for (const e of entries) log(statusLabel(e) + e.path + (e.binary ? c.dim(" (binary)") : ""))
43
+ return
44
+ }
45
+
46
+ for (const e of entries) {
47
+ if (e.binary) {
48
+ log(statusLabel(e) + e.path + c.dim(" (binary — content not shown)"))
49
+ continue
50
+ }
51
+ const d = unifiedDiff(e.serverText ?? "", e.localText ?? "", `server${e.path}`, `local${e.path}`)
52
+ if (d) log("\n" + d)
53
+ }
54
+ }
55
+
56
+ const statusLabel = (e: ServerDiffEntry): string =>
57
+ e.status === "added" ? c.green(" added ")
58
+ : e.status === "removed" ? c.red(" removed ")
59
+ : c.yellow(" modified ")
@@ -0,0 +1,191 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
2
+ import { basename, dirname, join, resolve } from "node:path"
3
+ import { LECODES_DIR } from "../manifest"
4
+ import { materializeTypesLocal, writeTsconfig } from "../types"
5
+ import { DEFAULT_IGNORE_FILE, ensureGitignore } from "../ignore"
6
+ import { appClaudeMd, CLAUDE_SETTINGS, STARTER_FLOW } from "./projectTemplates"
7
+ import { c, flagBool, log, note, success, warnErr, type Args } from "../util"
8
+
9
+ /*
10
+ * `lecodes init [dir]` — scaffold a brand-new LOCAL project: no account, no login, no server.
11
+ *
12
+ * Writes a starter entry, a CLAUDE.md (the headless render/test workflow for AI assistants) with
13
+ * a passing starter flow test, the SDK's import-free type surface + a tsconfig (so VS Code gives
14
+ * full IntelliSense offline), and a `.lecodesignore`. The result runs immediately with `lecodes dev`
15
+ * (hot reload on a device) and compiles with `lecodes compile -o app.js` (a self-contained bundle
16
+ * for LeCodes Desktop). It's the offline counterpart to `lecodes create`, which makes a project on
17
+ * the server; a local project needs no manifest — `dev`/`compile` compile straight from the tree.
18
+ *
19
+ * Idempotent: existing files are kept (never clobbered), so it's safe to re-run or run in a folder
20
+ * that already has content. Only the generated tsconfig + `.lecodes/types/` are rewritten each run.
21
+ */
22
+
23
+ const STARTER = `// Welcome to LeCodes — the whole SDK is available as globals, no imports needed.
24
+ // Run it live on a device with \`lecodes dev\`, or bundle it with \`lecodes compile -o app.js\`.
25
+
26
+ let count = 0
27
+
28
+ const counter = UIText({ color: "#9fb0d0", fontSize: 22, marginTop: 18 }, "Taps: 0")
29
+
30
+ const button = UIButton(
31
+ {
32
+ backgroundColor: "#4f7cff",
33
+ borderRadius: 14,
34
+ paddingVertical: 16,
35
+ paddingHorizontal: 40,
36
+ marginTop: 24,
37
+ alignItems: "center",
38
+ justifyContent: "center",
39
+ },
40
+ [UIText({ color: "#ffffff", fontSize: 22 }, "Tap me")],
41
+ )
42
+ .style({ name: "tap" }) // names make elements targetable in tests/*.flow.json (see CLAUDE.md)
43
+ .onClick(() => {
44
+ count++
45
+ counter.text = "Taps: " + count
46
+ })
47
+
48
+ const screen = UIScreen(
49
+ { backgroundColor: "#1e2230", alignItems: "center", justifyContent: "center", padding: 40 },
50
+ [
51
+ UIColumn(
52
+ { backgroundColor: "#2b3550", borderRadius: 24, padding: 40, alignItems: "center" },
53
+ [UIText({ color: "#ffffff", fontSize: 32 }, "Hello, LeCodes"), button, counter],
54
+ ),
55
+ ],
56
+ ).style({ name: "home" }) // the screen root's name is what tests' \`expect: { screen: … }\` reads
57
+
58
+ screen.open()
59
+ `
60
+
61
+ /** `--desktop`: declare the desktop target up front. Window sizes are logical units (the host
62
+ * scales for HiDPI); `dev --desktop` / `desktop run` / `render --desktop` all read this, and a
63
+ * desktop-only app.json makes headless render/test default to the window size too. */
64
+ const appJsonDesktop = (name: string) => JSON.stringify({
65
+ name,
66
+ desktop: {
67
+ window: { width: 1280, height: 800, minWidth: 640, minHeight: 480 },
68
+ },
69
+ }, null, 2) + "\n"
70
+
71
+ const readmeTemplate = (name: string) => `# ${name}
72
+
73
+ A LeCodes app. The whole SDK is available as globals (\`UIScreen\`, \`UIText\`, \`Scene2D\`, …) —
74
+ no imports needed. \`main.ts\` is the entry point.
75
+
76
+ ## Run it
77
+
78
+ \`\`\`
79
+ lecodes dev # live on your phone: scan the QR with the LeCodes app, hot reload
80
+ lecodes compile -o app.js # bundle to a single self-contained JS file
81
+ \`\`\`
82
+
83
+ ## Test it (no device needed)
84
+
85
+ \`\`\`
86
+ lecodes render --png shot.png # headless screenshot of the running app
87
+ lecodes test # run tests/*.flow.json — the starter ships with a passing one
88
+ \`\`\`
89
+
90
+ Scenario format + conventions: see [CLAUDE.md](./CLAUDE.md) (written for AI assistants, useful
91
+ for humans too).
92
+
93
+ ## Put it on the platform
94
+
95
+ This project is local-only. To give it a home on le.codes (web editor, sharing, OTA updates):
96
+
97
+ \`\`\`
98
+ lecodes login # once per machine
99
+ lecodes link # create a project from this folder and connect it
100
+ lecodes push -m "initial" # upload it as the first checkpoint (--publish to go live)
101
+ \`\`\`
102
+
103
+ Already made the project in the web editor? \`lecodes link --project <uuid>\` adopts it — it
104
+ shows what your next push would change there before connecting anything.
105
+
106
+ ## Go native
107
+
108
+ \`\`\`
109
+ lecodes app init ios # generate a native Xcode shell in ios/
110
+ lecodes app sync # embed the compiled bundle + plugins into it
111
+ lecodes app open # open it in Xcode, build & run
112
+ \`\`\`
113
+
114
+ ## Layout
115
+
116
+ - \`main.ts\` — the app's entry point. Add more .ts files freely and import them from
117
+ \`main.ts\`; a file named \`main.ts\` is always the entry.
118
+ - \`tests/\` — headless flow tests (\`lecodes test\`); failing steps dump the frame to
119
+ \`tests/.artifacts/\`
120
+ - \`CLAUDE.md\` — guidance for AI assistants (the headless feedback loop + testability conventions)
121
+ - \`assets/\` — images/fonts the app references (created on demand)
122
+ - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit).
123
+ They come from the SDK bundled in the CLI; \`lecodes update\` refreshes them from the platform.
124
+ - \`.lecodes/\` is gitignored — per-checkout state (those types + \`manifest.json\`, this machine's
125
+ sync cursor once linked). After a fresh git clone: \`lecodes update\`, then
126
+ \`lecodes link --project <uuid>\` to re-attach.
127
+ - \`.lecodesignore\` — files a future \`lecodes push\` would skip
128
+
129
+ Full command list: \`lecodes --help\`.
130
+ `
131
+
132
+ export const init = async (args: Args) => {
133
+ const dirArg = (args._[0] ?? ".").replace(/[/\\]+$/, "") || "."
134
+ const root = resolve(dirArg)
135
+ const name = basename(root)
136
+
137
+ mkdirSync(root, { recursive: true })
138
+
139
+ let created = 0
140
+ const write = (rel: string, content: string) => {
141
+ const abs = join(root, rel)
142
+ if (existsSync(abs)) { note(`kept ${rel}`); return }
143
+ mkdirSync(dirname(abs), { recursive: true })
144
+ writeFileSync(abs, content)
145
+ log(`${c.green("created")} ${rel}`)
146
+ created++
147
+ }
148
+
149
+ const forDesktop = flagBool(args, "desktop")
150
+
151
+ // Starter entry — a root file named main.ts is always the detected entrypoint.
152
+ write("main.ts", STARTER)
153
+ write("README.md", readmeTemplate(name))
154
+ // --desktop: an app.json declaring the desktop target (window config in logical units).
155
+ if (forDesktop) write("app.json", appJsonDesktop(name))
156
+ // AI-assistant guidance + a passing flow test: the headless loop is discoverable from day one.
157
+ write("CLAUDE.md", appClaudeMd(name))
158
+ write(".claude/settings.json", CLAUDE_SETTINGS)
159
+ write("tests/counter.flow.json", STARTER_FLOW)
160
+ // Keep local render/preview outputs (screenshot.png, shots/) out of any future push.
161
+ write(".lecodesignore", DEFAULT_IGNORE_FILE)
162
+ // .lecodes/ is per-checkout state (generated types + the push cursor) — keep it out of git.
163
+ const gi = ensureGitignore(root)
164
+ if (gi === "created") { log(`${c.green("created")} .gitignore`); created++ }
165
+ else if (gi === "updated") log(`${c.green("updated")} .gitignore ${c.dim("(+ .lecodes/)")}`)
166
+ else note("kept .gitignore")
167
+
168
+ // IDE types: the SDK's import-free global surface + a tsconfig, so VS Code resolves UIScreen,
169
+ // Scene2D, setLoop, … with no server. Regenerated every run to pick up CLI upgrades.
170
+ const wroteTypes = materializeTypesLocal(root)
171
+ writeTsconfig(root)
172
+ log(`${c.green("written")} tsconfig.json${wroteTypes ? ` + ${LECODES_DIR}/types/` : ""} (IDE types)`)
173
+ if (!wroteTypes) {
174
+ warnErr("Bundled SDK types not found — tsconfig written, but globals may show unresolved until the CLI is rebuilt with vendored types.")
175
+ }
176
+
177
+ log("")
178
+ success(created
179
+ ? `Local project ${c.bold(name)} ready${dirArg === "." ? "" : ` in ${c.bold(dirArg)}/`}.`
180
+ : `Project ${c.bold(name)} already initialized.`)
181
+ const cd = dirArg === "." ? "" : `cd ${dirArg} && `
182
+ if (forDesktop) {
183
+ log(` ${c.bold(cd + "lecodes dev --desktop")} run it in a desktop window with hot reload`)
184
+ log(` ${c.bold(cd + "lecodes test")} run the flow tests headless (desktop-sized)`)
185
+ log(` ${c.bold(cd + "lecodes desktop run")} open the compiled app in the desktop host`)
186
+ } else {
187
+ log(` ${c.bold(cd + "lecodes dev")} run it on a device with hot reload`)
188
+ log(` ${c.bold(cd + "lecodes test")} run the flow tests headless (no device)`)
189
+ log(` ${c.bold(cd + "lecodes compile -o app.js")} bundle it for LeCodes Desktop`)
190
+ }
191
+ }