lecodes-cli 0.19.0 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/dist/index.js +730 -235
  2. package/package.json +14 -13
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk-types.json +1 -1
  5. package/src/api.ts +302 -0
  6. package/src/browserAuth.ts +87 -0
  7. package/src/cmgenTool.ts +104 -0
  8. package/src/commands/app.ts +891 -0
  9. package/src/commands/appAndroid.ts +603 -0
  10. package/src/commands/appDesktop.ts +326 -0
  11. package/src/commands/appDesktopMac.ts +470 -0
  12. package/src/commands/appIcon.ts +187 -0
  13. package/src/commands/appShared.ts +448 -0
  14. package/src/commands/appTemplates.ts +883 -0
  15. package/src/commands/appTemplatesAndroid.ts +599 -0
  16. package/src/commands/appTemplatesGradlew.ts +9 -0
  17. package/src/commands/assets.ts +28 -0
  18. package/src/commands/clone.ts +60 -0
  19. package/src/commands/compile.ts +253 -0
  20. package/src/commands/create.ts +59 -0
  21. package/src/commands/design.ts +863 -0
  22. package/src/commands/designTemplates.ts +11 -0
  23. package/src/commands/desktop.ts +122 -0
  24. package/src/commands/dev.ts +214 -0
  25. package/src/commands/diff.ts +59 -0
  26. package/src/commands/init.ts +191 -0
  27. package/src/commands/install.ts +198 -0
  28. package/src/commands/lightmap.ts +290 -0
  29. package/src/commands/link.ts +147 -0
  30. package/src/commands/login.ts +24 -0
  31. package/src/commands/navmesh.ts +225 -0
  32. package/src/commands/pn.ts +305 -0
  33. package/src/commands/projectTemplates.ts +129 -0
  34. package/src/commands/pull.ts +109 -0
  35. package/src/commands/push.ts +173 -0
  36. package/src/commands/render.ts +414 -0
  37. package/src/commands/scene.ts +148 -0
  38. package/src/commands/shaders.ts +157 -0
  39. package/src/commands/status.ts +29 -0
  40. package/src/commands/test.ts +370 -0
  41. package/src/commands/thumbs.ts +178 -0
  42. package/src/commands/types.ts +87 -0
  43. package/src/commands/update.ts +190 -0
  44. package/src/compile/assetIcons.ts +214 -0
  45. package/src/compile/collect.ts +46 -0
  46. package/src/compile/collectLocal.ts +37 -0
  47. package/src/compile/designCompile.ts +104 -0
  48. package/src/compile/fonts.ts +156 -0
  49. package/src/compile/headlessBundle.ts +129 -0
  50. package/src/compile/nativeStack.ts +21 -0
  51. package/src/compile/projectCompile.ts +109 -0
  52. package/src/compile/sceneCompile.ts +113 -0
  53. package/src/compile/screenEntry.ts +127 -0
  54. package/src/compile/shaders.ts +245 -0
  55. package/src/config.ts +42 -0
  56. package/src/designMeta.ts +35 -0
  57. package/src/desktopRenderer.ts +532 -0
  58. package/src/desktopScript.ts +276 -0
  59. package/src/dev/clientTemplates.ts +160 -0
  60. package/src/dev/devServer.ts +292 -0
  61. package/src/dev/wsServer.ts +144 -0
  62. package/src/distRoot.ts +20 -0
  63. package/src/ignore.ts +163 -0
  64. package/src/index.ts +491 -0
  65. package/src/lecodes-3d-editor.d.ts +41 -0
  66. package/src/lecodes-assets.d.ts +7 -0
  67. package/src/lecodes-design.d.ts +191 -0
  68. package/src/lecodes-renderer.d.ts +131 -0
  69. package/src/localFiles.ts +144 -0
  70. package/src/manifest.ts +46 -0
  71. package/src/matcTool.ts +137 -0
  72. package/src/peers.ts +40 -0
  73. package/src/project.ts +61 -0
  74. package/src/projectEnv.ts +94 -0
  75. package/src/qrcode-terminal.d.ts +9 -0
  76. package/src/releases.ts +125 -0
  77. package/src/serverDiff.ts +78 -0
  78. package/src/textDiff.ts +103 -0
  79. package/src/types.ts +0 -0
  80. package/src/util.ts +146 -0
  81. package/runtime/sdk/animate/animate.ts +0 -238
  82. package/runtime/sdk/animate/bezier.ts +0 -138
  83. package/runtime/sdk/animate/easings.ts +0 -126
  84. package/runtime/sdk/canvas/Canvas.ts +0 -305
  85. package/runtime/sdk/compile/aspectMacro.ts +0 -86
  86. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  87. package/runtime/sdk/compile/assetMacro.ts +0 -146
  88. package/runtime/sdk/compile/assetName.ts +0 -50
  89. package/runtime/sdk/compile/bundler.ts +0 -287
  90. package/runtime/sdk/compile/compileProject.ts +0 -129
  91. package/runtime/sdk/compile/detectEntry.ts +0 -128
  92. package/runtime/sdk/compile/fontMacro.ts +0 -459
  93. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  94. package/runtime/sdk/compile/header.ts +0 -67
  95. package/runtime/sdk/compile/index.ts +0 -100
  96. package/runtime/sdk/compile/libraryImports.ts +0 -52
  97. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  98. package/runtime/sdk/compile/sceneEditor.ts +0 -88
  99. package/runtime/sdk/compile/serverSplit.ts +0 -233
  100. package/runtime/sdk/compile/serverTypes.ts +0 -227
  101. package/runtime/sdk/compile/sfnt.ts +0 -98
  102. package/runtime/sdk/compile/shaderSchema.ts +0 -202
  103. package/runtime/sdk/compile/shaderTargets.ts +0 -81
  104. package/runtime/sdk/compile/sourcemap.ts +0 -25
  105. package/runtime/sdk/core/Aspect.ts +0 -568
  106. package/runtime/sdk/core/InspectorUI.ts +0 -212
  107. package/runtime/sdk/core/StateMachine.ts +0 -308
  108. package/runtime/sdk/core/color.ts +0 -66
  109. package/runtime/sdk/core/compWrite.ts +0 -42
  110. package/runtime/sdk/core/events.ts +0 -38
  111. package/runtime/sdk/core/fields.ts +0 -120
  112. package/runtime/sdk/core/registry.ts +0 -23
  113. package/runtime/sdk/core/signals.ts +0 -277
  114. package/runtime/sdk/core/time.ts +0 -84
  115. package/runtime/sdk/g2/Camera2D.ts +0 -40
  116. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  117. package/runtime/sdk/g2/Node2D.ts +0 -267
  118. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  119. package/runtime/sdk/g2/Physics2D.ts +0 -346
  120. package/runtime/sdk/g2/Scene2D.ts +0 -209
  121. package/runtime/sdk/g2/Shape2D.ts +0 -259
  122. package/runtime/sdk/g2/Sprite.ts +0 -89
  123. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  124. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  125. package/runtime/sdk/g2/Texture2D.ts +0 -47
  126. package/runtime/sdk/g2/Tilemap.ts +0 -41
  127. package/runtime/sdk/g2/Tileset.ts +0 -71
  128. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  129. package/runtime/sdk/g2/autotile.ts +0 -433
  130. package/runtime/sdk/g2/cells.ts +0 -91
  131. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  132. package/runtime/sdk/g2/groups2d.ts +0 -106
  133. package/runtime/sdk/g2/loop.ts +0 -50
  134. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  135. package/runtime/sdk/g2/touch.ts +0 -83
  136. package/runtime/sdk/gl/Camera.ts +0 -160
  137. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  138. package/runtime/sdk/gl/CharacterController.ts +0 -238
  139. package/runtime/sdk/gl/Gearbox.ts +0 -212
  140. package/runtime/sdk/gl/Geometry.ts +0 -279
  141. package/runtime/sdk/gl/IK.ts +0 -193
  142. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  143. package/runtime/sdk/gl/Light.ts +0 -107
  144. package/runtime/sdk/gl/Lightmap.ts +0 -256
  145. package/runtime/sdk/gl/Material.ts +0 -260
  146. package/runtime/sdk/gl/Mesh.ts +0 -83
  147. package/runtime/sdk/gl/Model.ts +0 -124
  148. package/runtime/sdk/gl/NavAgent.ts +0 -337
  149. package/runtime/sdk/gl/NavMesh.ts +0 -397
  150. package/runtime/sdk/gl/Node.ts +0 -350
  151. package/runtime/sdk/gl/Noise.ts +0 -30
  152. package/runtime/sdk/gl/Particles.ts +0 -676
  153. package/runtime/sdk/gl/Physics.ts +0 -222
  154. package/runtime/sdk/gl/Plane.ts +0 -53
  155. package/runtime/sdk/gl/Ragdoll.ts +0 -270
  156. package/runtime/sdk/gl/Ray.ts +0 -16
  157. package/runtime/sdk/gl/Scene.ts +0 -486
  158. package/runtime/sdk/gl/Shape.ts +0 -409
  159. package/runtime/sdk/gl/Terrain.ts +0 -1092
  160. package/runtime/sdk/gl/Texture.ts +0 -63
  161. package/runtime/sdk/gl/Trigger.ts +0 -45
  162. package/runtime/sdk/gl/Vehicle.ts +0 -473
  163. package/runtime/sdk/gl/Wheel.ts +0 -240
  164. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  165. package/runtime/sdk/gl/animation/Animator.ts +0 -120
  166. package/runtime/sdk/gl/animation/Layer.ts +0 -31
  167. package/runtime/sdk/gl/animation/Loop.ts +0 -30
  168. package/runtime/sdk/gl/animation/Playback.ts +0 -44
  169. package/runtime/sdk/gl/animation/core.ts +0 -336
  170. package/runtime/sdk/gl/controls.ts +0 -95
  171. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  172. package/runtime/sdk/gl/scenarios.ts +0 -291
  173. package/runtime/sdk/gl/state.ts +0 -6
  174. package/runtime/sdk/gl/terrainMesh.ts +0 -219
  175. package/runtime/sdk/gl/touch.ts +0 -68
  176. package/runtime/sdk/inject.ts +0 -197
  177. package/runtime/sdk/math/Mathf.ts +0 -118
  178. package/runtime/sdk/math/mat4.ts +0 -278
  179. package/runtime/sdk/math/quat.ts +0 -232
  180. package/runtime/sdk/math/vec.ts +0 -255
  181. package/runtime/sdk/net/codec.ts +0 -119
  182. package/runtime/sdk/net/core.ts +0 -384
  183. package/runtime/sdk/net/index.ts +0 -181
  184. package/runtime/sdk/net/replication.ts +0 -622
  185. package/runtime/sdk/plugins/camera.ts +0 -81
  186. package/runtime/sdk/plugins/geolocation.ts +0 -123
  187. package/runtime/sdk/plugins/oauth.ts +0 -61
  188. package/runtime/sdk/plugins/permission.ts +0 -7
  189. package/runtime/sdk/plugins/push.ts +0 -132
  190. package/runtime/sdk/plugins/qr.ts +0 -73
  191. package/runtime/sdk/plugins/service.ts +0 -47
  192. package/runtime/sdk/runtime/app.ts +0 -101
  193. package/runtime/sdk/runtime/appEvents.ts +0 -54
  194. package/runtime/sdk/runtime/channel.ts +0 -61
  195. package/runtime/sdk/runtime/clipboard.ts +0 -20
  196. package/runtime/sdk/runtime/datetime.ts +0 -329
  197. package/runtime/sdk/runtime/device.ts +0 -293
  198. package/runtime/sdk/runtime/fetch.ts +0 -77
  199. package/runtime/sdk/runtime/files.ts +0 -23
  200. package/runtime/sdk/runtime/input.ts +0 -175
  201. package/runtime/sdk/runtime/media.ts +0 -111
  202. package/runtime/sdk/runtime/misc.ts +0 -16
  203. package/runtime/sdk/runtime/net.ts +0 -36
  204. package/runtime/sdk/runtime/rpc.ts +0 -218
  205. package/runtime/sdk/runtime/service.ts +0 -83
  206. package/runtime/sdk/runtime/share.ts +0 -9
  207. package/runtime/sdk/runtime/storage.ts +0 -13
  208. package/runtime/sdk/runtime/touch.ts +0 -76
  209. package/runtime/sdk/scene/defineScene.ts +0 -1353
  210. package/runtime/sdk/scene/editorPlugins.ts +0 -110
  211. package/runtime/sdk/scene/gizmos.ts +0 -148
  212. package/runtime/sdk/scene/grammar.ts +0 -120
  213. package/runtime/sdk/scene/material.ts +0 -188
  214. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  215. package/runtime/sdk/server/auth/global.ts +0 -80
  216. package/runtime/sdk/server/auth/host.ts +0 -318
  217. package/runtime/sdk/server/auth/models.ts +0 -83
  218. package/runtime/sdk/server/auth/types.ts +0 -50
  219. package/runtime/sdk/server/channel.ts +0 -56
  220. package/runtime/sdk/server/context.ts +0 -36
  221. package/runtime/sdk/server/db/defineDb.ts +0 -237
  222. package/runtime/sdk/server/db/fields.ts +0 -132
  223. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  224. package/runtime/sdk/server/db/index.ts +0 -7
  225. package/runtime/sdk/server/db/marci/query.ts +0 -412
  226. package/runtime/sdk/server/db/types.ts +0 -202
  227. package/runtime/sdk/server/errors.ts +0 -12
  228. package/runtime/sdk/server/host.ts +0 -74
  229. package/runtime/sdk/server/inject.ts +0 -13
  230. package/runtime/sdk/server/runtime.ts +0 -133
  231. package/runtime/sdk/server/validate.ts +0 -87
  232. package/runtime/sdk/ui/NativeView.ts +0 -142
  233. package/runtime/sdk/ui/UI.ts +0 -39
  234. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  235. package/runtime/sdk/ui/UIButton.ts +0 -101
  236. package/runtime/sdk/ui/UIContainer.ts +0 -60
  237. package/runtime/sdk/ui/UIImage.ts +0 -83
  238. package/runtime/sdk/ui/UIInput.ts +0 -185
  239. package/runtime/sdk/ui/UIModal.ts +0 -139
  240. package/runtime/sdk/ui/UINode.ts +0 -830
  241. package/runtime/sdk/ui/UIPager.ts +0 -362
  242. package/runtime/sdk/ui/UIPopover.ts +0 -100
  243. package/runtime/sdk/ui/UIScreen.ts +0 -123
  244. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  245. package/runtime/sdk/ui/UISpacer.ts +0 -14
  246. package/runtime/sdk/ui/UITabs.ts +0 -236
  247. package/runtime/sdk/ui/UIText.ts +0 -51
  248. package/runtime/sdk/ui/UIVideo.ts +0 -88
  249. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  250. package/runtime/sdk/ui/UIWidget.ts +0 -127
  251. package/runtime/sdk/ui/fonts.ts +0 -13
  252. package/runtime/sdk/ui/presentable.ts +0 -117
  253. package/runtime/sdk/ui/router.ts +0 -132
  254. package/runtime/sdk/ui/theme.ts +0 -84
@@ -0,0 +1,198 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync } from "node:fs"
3
+ import { homedir } from "node:os"
4
+ import { basename, delimiter, dirname, join, resolve, sep } from "node:path"
5
+ import { c, CliError, flagBool, flagStr, log, success, warn, type Args } from "../util"
6
+
7
+ /*
8
+ * `lecodes install` — install this standalone build so `lecodes` is on your PATH.
9
+ *
10
+ * The packaged CLI is a small BUNDLE, not a single file: the `lecodes` binary + the `chisel` bundler +
11
+ * `runtime/` (the vendored SDK source + types). The binary resolves those companions next to its REAL
12
+ * path (process.execPath, which follows symlinks — verified), so "install" just means: copy the bundle
13
+ * into a stable library folder and expose the binary on PATH.
14
+ * - Linux/macOS: copy to <libdir>/, symlink <bindir>/lecodes -> <libdir>/lecodes.
15
+ * - Windows: copy to <dir>\ and put that dir on PATH (no symlink, no admin needed).
16
+ *
17
+ * PATH is only edited on request (`--modify-path`); otherwise we place the files and print exactly how
18
+ * to finish (a one-liner for this shell, a line for the rc file, or `--system` which lands in
19
+ * /usr/local/bin and needs no PATH edit). Only meaningful for the release archive — from npm/monorepo
20
+ * source process.execPath has no bundle beside it, so it refuses.
21
+ */
22
+
23
+ const isWin = process.platform === "win32"
24
+ const HOME = homedir()
25
+
26
+ /** Display a path with the home dir collapsed to `~` (for copy-pasteable hints). */
27
+ const tildify = (p: string): string => (p === HOME || p.startsWith(HOME + sep)) ? "~" + p.slice(HOME.length) : p
28
+
29
+ const safeRealpath = (p: string): string => { try { return realpathSync(p) } catch { return resolve(p) } }
30
+
31
+ /** True if `dir` is already an entry in PATH (compared by resolved path). */
32
+ const onPath = (dir: string): boolean => {
33
+ const want = safeRealpath(dir)
34
+ return (process.env.PATH ?? "").split(delimiter).some(p => {
35
+ if (!p) return false
36
+ try { return safeRealpath(p) === want } catch { return false }
37
+ })
38
+ }
39
+
40
+ export const install = async (args: Args): Promise<void> => {
41
+ const bundle = dirname(process.execPath)
42
+ const exeName = basename(process.execPath) // "lecodes" | "lecodes.exe"
43
+ const chiselName = isWin ? "chisel.exe" : "chisel"
44
+
45
+ // Guard: this must be the packaged standalone bundle (chisel + runtime/ sit next to the binary).
46
+ // From npm / the monorepo, process.execPath is the bun/node binary and has no companions.
47
+ if (!existsSync(join(bundle, "runtime")) || !existsSync(join(bundle, chiselName))) {
48
+ throw new CliError(
49
+ "`lecodes install` only works from the packaged release build (the archive containing lecodes + " +
50
+ "chisel + runtime/). You appear to be running from source or an npm install — install globally " +
51
+ "with your package manager there instead.",
52
+ )
53
+ }
54
+
55
+ const companions = [exeName, chiselName, "runtime", "README.txt"].filter(f => existsSync(join(bundle, f)))
56
+ if (isWin) return installWindows(args, bundle, companions)
57
+ return installUnix(args, bundle, companions)
58
+ }
59
+
60
+ /** Copy the bundle's companion files into `target` (skipping the copy when target IS the bundle). */
61
+ const copyBundle = (bundle: string, target: string, companions: string[]): void => {
62
+ mkdirSync(target, { recursive: true })
63
+ if (safeRealpath(target) === safeRealpath(bundle)) return // installing in place — nothing to copy
64
+ for (const f of companions) {
65
+ const dest = join(target, f)
66
+ rmSync(dest, { recursive: true, force: true })
67
+ cpSync(join(bundle, f), dest, { recursive: true })
68
+ }
69
+ }
70
+
71
+ const permError = (e: unknown, dir: string, system: boolean): CliError => {
72
+ const code = (e as NodeJS.ErrnoException | null)?.code
73
+ if (code === "EACCES" || code === "EPERM") {
74
+ return new CliError(
75
+ `Permission denied writing to ${dir}.` +
76
+ (system ? " Re-run with sudo: sudo lecodes install --system" : " Try without --system, or pick a writable --dir."),
77
+ )
78
+ }
79
+ return new CliError(`Install failed: ${e instanceof Error ? e.message : String(e)}`)
80
+ }
81
+
82
+ const installUnix = (args: Args, bundle: string, companions: string[]): void => {
83
+ const system = flagBool(args, "system")
84
+ const libDir = resolve(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join(HOME, ".local", "lib", "lecodes-cli")))
85
+ const binDir = resolve(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join(HOME, ".local", "bin")))
86
+ const link = join(binDir, "lecodes")
87
+
88
+ try {
89
+ copyBundle(bundle, libDir, companions)
90
+ chmodSync(join(libDir, "lecodes"), 0o755)
91
+ chmodSync(join(libDir, "chisel"), 0o755)
92
+ } catch (e) { throw permError(e, libDir, system) }
93
+
94
+ try {
95
+ mkdirSync(binDir, { recursive: true })
96
+ rmSync(link, { force: true }) // replace a previous symlink/file
97
+ symlinkSync(join(libDir, "lecodes"), link)
98
+ } catch (e) { throw permError(e, binDir, system) }
99
+
100
+ success(`Installed to ${c.bold(libDir)}`)
101
+ log(` ${c.dim(link + " → " + join(libDir, "lecodes"))}`)
102
+ finishPathUnix(args, binDir)
103
+ }
104
+
105
+ /** The shell startup file to persist PATH into, picked from $SHELL (bash/zsh handled; else ~/.profile,
106
+ * which login shells read). Returns the absolute path and a ~-collapsed display name. */
107
+ const shellRc = (): { file: string, name: string } => {
108
+ const shell = basename(process.env.SHELL ?? "")
109
+ if (shell === "zsh") return { file: join(HOME, ".zshrc"), name: "~/.zshrc" }
110
+ if (shell === "bash") return { file: join(HOME, ".bashrc"), name: "~/.bashrc" }
111
+ return { file: join(HOME, ".profile"), name: "~/.profile" }
112
+ }
113
+
114
+ const PATH_MARKER = "# Added by `lecodes install`"
115
+
116
+ /** Append an `export PATH=…` line for `binDir` to the shell rc (idempotently). Returns the rc display
117
+ * name on success, or null if the file couldn't be written. */
118
+ const appendPathToShellRc = (binDir: string): string | null => {
119
+ const { file, name } = shellRc()
120
+ const line = `export PATH="${binDir}:$PATH"`
121
+ try {
122
+ const existing = existsSync(file) ? readFileSync(file, "utf8") : ""
123
+ if (existing.includes(line)) return name // already present — no duplicate
124
+ appendFileSync(file, `${existing && !existing.endsWith("\n") ? "\n" : ""}\n${PATH_MARKER}\n${line}\n`)
125
+ return name
126
+ } catch { return null }
127
+ }
128
+
129
+ /** Put `binDir` on PATH: verify (already there), or `--modify-path` (edit the rc), or print how. */
130
+ const finishPathUnix = (args: Args, binDir: string): void => {
131
+ if (onPath(binDir)) {
132
+ log(` Run ${c.bold("lecodes --version")} in a new shell to verify.`)
133
+ return
134
+ }
135
+ if (flagBool(args, "modify-path")) {
136
+ const rc = appendPathToShellRc(binDir)
137
+ if (rc) {
138
+ success(`Added ${c.bold(binDir)} to PATH in ${rc}.`)
139
+ log(` Run ${c.bold(`source ${rc}`)} (or open a new terminal), then ${c.bold("lecodes --version")}.`)
140
+ } else {
141
+ warn("Couldn't edit your shell startup file. Add this line to it manually:")
142
+ log(` ${c.bold(`export PATH="${binDir}:$PATH"`)}`)
143
+ }
144
+ return
145
+ }
146
+ const { name } = shellRc()
147
+ warn(`${binDir} is not on your PATH yet. To finish, either:`)
148
+ log(` • this shell now: ${c.bold(`export PATH="${binDir}:$PATH"`)}`)
149
+ log(` • all new shells: ${c.bold(`echo 'export PATH="${binDir}:$PATH"' >> ${name}`)} ${c.dim("(then reopen it)")}`)
150
+ log(` • let install do it: re-run with ${c.bold("--modify-path")}`)
151
+ log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`)
152
+ }
153
+
154
+ const installWindows = (args: Args, bundle: string, companions: string[]): void => {
155
+ const target = resolve(flagStr(args, "dir") ?? join(process.env.LOCALAPPDATA ?? join(HOME, "AppData", "Local"), "Programs", "lecodes-cli"))
156
+
157
+ try {
158
+ copyBundle(bundle, target, companions)
159
+ } catch (e) {
160
+ throw new CliError(`Install failed writing to ${target}: ${e instanceof Error ? e.message : String(e)}`)
161
+ }
162
+
163
+ success(`Installed to ${c.bold(target)}`)
164
+ // No symlink on Windows (they need admin) and no shim needed — the exe resolves chisel/runtime next to
165
+ // itself, so putting the install folder itself on PATH is enough.
166
+ if (onPath(target)) {
167
+ log(` Already on PATH — run ${c.bold("lecodes --version")} in a new terminal.`)
168
+ return
169
+ }
170
+ if (flagBool(args, "modify-path")) {
171
+ if (addToUserPathWindows(target)) {
172
+ success(`Added ${c.bold(target)} to your user PATH.`)
173
+ log(` ${c.yellow("Open a NEW terminal")} for it to take effect, then run ${c.bold("lecodes --version")}.`)
174
+ } else {
175
+ warn("Couldn't edit PATH automatically. Add this folder to your PATH manually:")
176
+ log(` ${c.bold(target)}`)
177
+ }
178
+ return
179
+ }
180
+ warn(`${target} is not on your PATH yet. To finish, either:`)
181
+ log(` • let install do it: re-run with ${c.bold("--modify-path")} (adds it to your user PATH)`)
182
+ log(` • or add the folder manually via System → Environment Variables.`)
183
+ }
184
+
185
+ /** Append `dir` to the current user's Path via the .NET env API (PowerShell). Unlike `setx` this never
186
+ * truncates a long Path. The dir is passed through an env var to avoid any quoting/injection. */
187
+ const addToUserPathWindows = (dir: string): boolean => {
188
+ const script =
189
+ "$d = $env:LECODES_INSTALL_DIR;" +
190
+ "$p = [Environment]::GetEnvironmentVariable('Path','User'); if (-not $p) { $p = '' };" +
191
+ "$parts = @($p.Split(';') | Where-Object { $_ -ne '' });" +
192
+ "if ($parts -notcontains $d) { [Environment]::SetEnvironmentVariable('Path', (@($parts + $d) -join ';'), 'User') }"
193
+ try {
194
+ execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script],
195
+ { stdio: "ignore", env: { ...process.env, LECODES_INSTALL_DIR: dir } })
196
+ return true
197
+ } catch { return false }
198
+ }
@@ -0,0 +1,290 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
2
+ import { spawn, spawnSync } from "node:child_process"
3
+ import { tmpdir } from "node:os"
4
+ import { join, relative, resolve } from "node:path"
5
+ import { findProjectRoot } from "../manifest"
6
+ import { buildBundle } from "./compile"
7
+ import { readAppConfigAt, resolveDesktopRenderer } from "./appShared"
8
+ import { runDesktopRender } from "../desktopRenderer"
9
+ import { CliError, c, flagStr, flagBool, logErr, note, warnErr, type Args } from "../util"
10
+
11
+ /*
12
+ * `lecodes lightmap bake` — bake static shadows, ambient occlusion and the level's point lights for
13
+ * the local project (docs/lightmap-plan.md). The app is compiled with a synthetic entry that puts it
14
+ * in BAKE mode (`globalThis.__lecodesLightmap = { outDir, … }`, then `import "./main"`) and run in the
15
+ * native desktop host; when the app calls `Lightmap.load(...)` — the point where its scene is
16
+ * complete — the SDK sees the flag and calls `_creator.lightmapBake` instead of loading: creator-gl
17
+ * traces every `Lightmap.add`-registered static against the scene's sun and point lights and writes
18
+ * its rasters (PNGs, INTERMEDIATES) + lightmap.bake into --out. The host's stdout carries
19
+ * "[lightmap] …" progress lines; "[lightmap] done" is the success marker. The atlases then ship as
20
+ * **KTX2**: the CLI runs KTX-Software's toktx (UASTC + zstd + mips, sRGB transfer function — the bake
21
+ * encodes sRGB on purpose, precision in the darks) and deletes the PNGs. toktx is REQUIRED — the
22
+ * command refuses to start without it, so a bake never leaves a half-finished output behind.
23
+ *
24
+ * Output, under --out with the --name stem (default `lightmap`, so a project with several baked
25
+ * levels gives each its own stem — `app build desktop` rejects two resources of one basename):
26
+ * <name>.bake rects, pages, the baked lights (what the scene's `data` names)
27
+ * <name>.ktx2, <name>_1.ktx2… the atlas pages — R = sun visibility, G = ambient occlusion
28
+ * <name>-light[_n].ktx2 the point lights' baked irradiance, only when the level holds any
29
+ * <name>.volume the light volume for movers (unless --no-volume)
30
+ *
31
+ * Only the desktop host has the bake module (a dev tool — never in the wasm / mobile engines), so
32
+ * this needs a host with it: a local build via LECODES_DESKTOP_EXE until the release ships it.
33
+ * The props need lightmap UVs first: `lecodes assets doctor assets/props/*.glb --lightmap-uv`.
34
+ */
35
+
36
+ const num = (args: Args, key: string, fallback: number, min: number, max: number): number => {
37
+ const raw = flagStr(args, key)
38
+ if (raw === undefined) return fallback
39
+ const n = Number(raw)
40
+ if (!Number.isFinite(n) || n < min || n > max) throw new CliError(`--${key} wants a number in ${min}..${max}, got "${raw}".`)
41
+ return n
42
+ }
43
+
44
+ // ---- toktx (KTX-Software) — same lookup as lecodes-assets' doctor --------------------------------
45
+
46
+ const findToktx = (): string => {
47
+ const env = process.env.KTX_TOKTX
48
+ if (env && existsSync(env)) return env
49
+ for (const base of [ process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "Programs"), process.env.ProgramFiles ]) {
50
+ if (!base) continue
51
+ const guess = join(base, "KTX-Software", "bin", "toktx.exe")
52
+ if (existsSync(guess)) return guess
53
+ }
54
+ return "toktx" // on PATH
55
+ }
56
+
57
+ const checkToktx = (toktx: string): string | null => {
58
+ try {
59
+ const p = spawnSync(toktx, [ "--version" ], { encoding: "utf8" })
60
+ if (p.status === 0) return (p.stdout ?? "").toString().trim() || "toktx"
61
+ } catch { /* not found */ }
62
+ return null
63
+ }
64
+
65
+ const requireToktx = (): string => {
66
+ const toktx = findToktx()
67
+ if (!checkToktx(toktx)) {
68
+ throw new CliError("lecodes lightmap bake needs KTX-Software's toktx — the atlas ships as KTX2 (GPU-compressed, mipmapped).\n Install it from https://github.com/KhronosGroup/KTX-Software/releases (make sure toktx is on PATH) or set KTX_TOKTX=<path to toktx>.")
69
+ }
70
+ return toktx
71
+ }
72
+
73
+ /** PNG raster → .ktx2: UASTC (the high-quality Basis codec — ETC1S blocks the shadow edges),
74
+ * zstd supercompression, a full mip chain, sRGB transfer function (what the bake encodes and what
75
+ * the ordinary texture path expects; the KTX2 reader refuses a mismatch instead of guessing). */
76
+ // Async so the pages encode CONCURRENTLY: toktx's zstd + mip stages are serial per file, and the four
77
+ // pages of a two-page bake with a light atlas took 23 s one after another against 10 s side by side.
78
+ const encodeKtx2 = (toktx: string, png: string, ktx2: string): Promise<void> => new Promise((resolve, reject) => {
79
+ const args = [ "--t2", "--encode", "uastc", "--uastc_quality", "2", "--zcmp", "18", "--genmipmap", "--assign_oetf", "srgb", ktx2, png ]
80
+ const p = spawn(toktx, args, { stdio: [ "ignore", "pipe", "pipe" ] })
81
+ let out = ""
82
+ p.stdout.on("data", (d) => { out += String(d) })
83
+ p.stderr.on("data", (d) => { out += String(d) })
84
+ p.on("error", (e) => reject(new CliError(`toktx failed (${e.message}) while encoding ${ktx2}`)))
85
+ p.on("close", (status) => {
86
+ if (status === 0) resolve()
87
+ else reject(new CliError(`toktx failed (${status ?? "?"}) while encoding ${ktx2}:\n${out.trim()}`))
88
+ })
89
+ })
90
+
91
+ /** The bake's file names for `stem`: page 0 is `<stem>.ktx2`, page n `<stem>_n.ktx2`; the light atlas adds `-light`. */
92
+ const pageFile = (stem: string, page: number, light: boolean, ext = ".ktx2"): string =>
93
+ `${stem}${light ? "-light" : ""}${page > 0 ? `_${page}` : ""}${ext}`
94
+
95
+ const bake = async (args: Args): Promise<void> => {
96
+ let root: string
97
+ try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() }
98
+ const toktx = requireToktx()
99
+ const outDir = resolve(root, flagStr(args, "out") ?? "assets/lightmap")
100
+ const stem = flagStr(args, "name") ?? "lightmap"
101
+ if (!/^[A-Za-z0-9_-]+$/.test(stem)) throw new CliError(`--name wants a plain file stem (letters, digits, - and _), got "${stem}".`)
102
+ const entryFlag = flagStr(args, "entry")
103
+ const entryFile = entryFlag ?? "main.ts"
104
+ if (!existsSync(resolve(root, entryFile))) {
105
+ throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the scene and calls Lightmap.load).`)
106
+ }
107
+ const cfg = {
108
+ outDir: outDir.replace(/\\/g, "/"),
109
+ size: num(args, "size", 4096, 256, 16384),
110
+ texel: num(args, "texel", 0.02, 0.001, 2),
111
+ // atlas PAGES: the bake fills up to this many atlases of --size at the requested texel before it
112
+ // coarsens; every page is one more texture the scene lists under env.lightmap.texture
113
+ pages: num(args, "pages", 4, 1, 16),
114
+ sunRays: num(args, "sun-rays", 32, 1, 1024),
115
+ aoRays: num(args, "ao-rays", 64, 1, 4096),
116
+ aoDistance: num(args, "ao-distance", 2, 0.01, 1000),
117
+ bias: num(args, "bias", 0.02, 0.0001, 1),
118
+ sunAngle: num(args, "sun-angle", 0.5, 0.01, 30),
119
+ // point lights: shadow rays per light per texel, toward a disc of this radius at the light
120
+ lightRays: num(args, "light-rays", 16, 1, 256),
121
+ lightRadius: num(args, "light-radius", 0.1, 0.001, 5),
122
+ // One bounce of indirect from the lamps, gathered on the AO rays (the albedo it assumes for
123
+ // every surface it hits). Off by default: it is the difference between "a room lit by its
124
+ // lamps" and "lit fixtures in a black room", but it costs a full Intersect per AO ray.
125
+ bounce: num(args, "bounce", 0, 0, 1),
126
+ bounceDistance: num(args, "bounce-distance", 8, 0.5, 100),
127
+ bounceRays: num(args, "bounce-rays", 1, 1, 32),
128
+ coplanarSkip: num(args, "coplanar-skip", 0.05, 0, 1),
129
+ // geometry-aware denoise of the traced texels (radius in px; 0 = off)
130
+ denoise: num(args, "denoise", 2, 0, 4),
131
+ // the light volume for dynamic objects (docs/lightmap-plan.md §9); 0 = skip it
132
+ volumeCell: flagBool(args, "no-volume") ? 0 : num(args, "cell", 0.5, 0.05, 20),
133
+ }
134
+ const frames = num(args, "frames", 900, 30, 100000)
135
+ const win = readAppConfigAt(root)?.desktop?.window
136
+ const width = Number(flagStr(args, "width")) || win?.width || 1280
137
+ const height = Number(flagStr(args, "height")) || win?.height || 720
138
+ const logs = flagBool(args, "logs")
139
+
140
+ // The synthetic entry: bake mode flag first, then the real entry. It lives at the project root so
141
+ // the real entry's own relative imports resolve unchanged.
142
+ const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
143
+ const entryPath = "/.lightmap-entry.ts"
144
+ const entryText = `globalThis.__lecodesLightmap = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
145
+
146
+ // The bake files must exist BEFORE the compile: the scene references them through asset(), and
147
+ // a missing asset is a broken bundle (chisel emits the constant after its use — a TDZ crash at
148
+ // boot, seen 2026-08-26). The first bake starts from placeholders the real files overwrite;
149
+ // Lightmap.load on a placeholder applies nothing and says "rebake".
150
+ mkdirSync(outDir, { recursive: true })
151
+ ensurePlaceholders(outDir, stem)
152
+
153
+ const desktopRenderer = resolveDesktopRenderer(root, args)
154
+ const { js, resources } = await buildBundle(args, undefined, {
155
+ localAssets: true, desktopRenderer,
156
+ extraEntries: [{ path: entryPath, type: "text", text: entryText }],
157
+ entryOverride: entryPath,
158
+ })
159
+
160
+ // The host writes the bake itself; the PNG the render driver insists on goes to a scratch dir.
161
+ const scratch = mkdtempSync(join(tmpdir(), "lecodes-lightmap-"))
162
+ const outPng = join(scratch, "frame.png")
163
+ note(`Baking up to ${cfg.pages} × ${cfg.size}² at ${(cfg.texel * 100).toFixed(1)} cm texels (${cfg.sunRays} sun + ${cfg.aoRays} AO + ${cfg.lightRays} light rays)${cfg.volumeCell ? ` + a ${cfg.volumeCell} m light volume` : ""} → ${relative(root, outDir) || "."}/${stem}.*…`)
164
+ let output = ""
165
+ try {
166
+ // A bake is not interactive and a large level traces for a long time: no timeout unless asked
167
+ // (the host prints its progress once a second — a silent host is the thing to watch, not the clock).
168
+ const timeoutMs = Number(flagStr(args, "timeout")) || 24 * 3600 * 1000
169
+ const res = await runDesktopRender(js, resources, {
170
+ renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs,
171
+ // the bake is synchronous inside Lightmap.load — once the marker is out there is nothing to wait for
172
+ stopOnLine: (chunk) => chunk.includes("[lightmap] done") || chunk.includes("[lightmap] error"),
173
+ // no script: the frame cap ends the run once Lightmap.load has fired (the bake is synchronous)
174
+ })
175
+ output = res.output
176
+ } catch (e) {
177
+ const msg = e instanceof Error ? e.message : String(e)
178
+ // A run that baked and then hit the cap without a PNG is still a success — check the markers.
179
+ if (!/\[lightmap\] done/.test(msg)) throw e
180
+ output = msg
181
+ } finally {
182
+ try { rmSync(scratch, { recursive: true, force: true }) } catch { /* a killed host may hold the dir a moment longer */ }
183
+ }
184
+
185
+ const lines = output.split(/\r?\n/).filter((l) => l.startsWith("[lightmap]"))
186
+ for (const l of lines) {
187
+ const text = l.slice("[lightmap]".length).trim()
188
+ if (text === "done") continue
189
+ logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : text.startsWith("warning") ? c.yellow(` ${text}`) : c.dim(` ${text}`))
190
+ }
191
+ if (/\[lightmap\] done/.test(output)) {
192
+ // the host writes `lightmap.*`; the stem is the CLI's, applied while the PNGs turn into KTX2
193
+ const bakePath = join(outDir, "lightmap.bake")
194
+ if (!existsSync(bakePath)) throw new CliError(`The host reported a bake but ${bakePath} is missing.`)
195
+ const data = JSON.parse(readFileSync(bakePath, "utf8")) as { pages?: number, textures?: string[], lightTextures?: string[], volume?: { file: string }, texture?: string }
196
+ const pages = Math.max(1, data.pages ?? 1)
197
+ const hasLight = !!data.lightTextures?.length
198
+ const t0 = Date.now()
199
+ let pngKb = 0, ktxKb = 0
200
+ const encodePage = async (page: number, light: boolean): Promise<void> => {
201
+ const png = join(outDir, pageFile("lightmap", page, light, ".png"))
202
+ const ktx2 = join(outDir, pageFile(stem, page, light))
203
+ if (!existsSync(png)) throw new CliError(`The host reported a bake but ${png} is missing.`)
204
+ await encodeKtx2(toktx, png, ktx2)
205
+ pngKb += statSync(png).size / 1024; ktxKb += statSync(ktx2).size / 1024
206
+ unlinkSync(png)
207
+ }
208
+ const encodes: Promise<void>[] = []
209
+ for (let p = 0; p < pages; p++) {
210
+ encodes.push(encodePage(p, false))
211
+ if (hasLight) encodes.push(encodePage(p, true))
212
+ }
213
+ await Promise.all(encodes)
214
+ // a page the previous bake had and this one does not: keep the file a scene may still list, as a placeholder
215
+ for (let p = pages; p < 16; p++) {
216
+ for (const light of [ false, true ]) {
217
+ const stale = join(outDir, pageFile(stem, p, light))
218
+ if (existsSync(stale) && statSync(stale).size !== PLACEHOLDER_KTX2.length) writeFileSync(stale, PLACEHOLDER_KTX2)
219
+ }
220
+ }
221
+ if (!hasLight) {
222
+ // no point lights in the level: a light atlas the scene may still reference goes back to a placeholder
223
+ const light = join(outDir, pageFile(stem, 0, true))
224
+ if (existsSync(light) && statSync(light).size !== PLACEHOLDER_KTX2.length) writeFileSync(light, PLACEHOLDER_KTX2)
225
+ }
226
+ // the .bake names the encoded files under the stem
227
+ data.texture = pageFile(stem, 0, false)
228
+ data.textures = Array.from({ length: pages }, (_, p) => pageFile(stem, p, false))
229
+ if (hasLight) data.lightTextures = Array.from({ length: pages }, (_, p) => pageFile(stem, p, true))
230
+ if (data.volume) data.volume.file = `${stem}.volume`
231
+ writeFileSync(join(outDir, `${stem}.bake`), JSON.stringify(data, null, 1) + "\n")
232
+ if (stem !== "lightmap") unlinkSync(bakePath)
233
+ const volumeSrc = join(outDir, "lightmap.volume")
234
+ const hasVolume = cfg.volumeCell > 0 && existsSync(volumeSrc)
235
+ if (hasVolume && stem !== "lightmap") { rmSync(join(outDir, `${stem}.volume`), { force: true }); renameSync(volumeSrc, join(outDir, `${stem}.volume`)) }
236
+ logErr(c.dim(` encoded ${pages} page(s)${hasLight ? " + the light atlas" : ""} (UASTC + mips, ${Math.round(ktxKb)} KB from ${Math.round(pngKb)} KB of rasters) in ${((Date.now() - t0) / 1000).toFixed(1)} s`))
237
+ logErr(c.green(`✓ baked → ${relative(root, outDir) || "."}/${stem}.bake + ${data.textures.join(", ")}${hasLight ? ` + ${data.lightTextures!.join(", ")}` : ""}${hasVolume ? ` + ${stem}.volume` : ""}`))
238
+ const rel = (f: string): string => relative(root, join(outDir, f)).replace(/\\/g, "/")
239
+ const assets = (files: string[]): string => files.length === 1 ? `asset('<rel>/${rel(files[0])}')` : `[${files.map((f) => `asset('<rel>/${rel(f)}')`).join(", ")}]`
240
+ logErr(c.dim(` scene file: env.lightmap: { data: asset('<rel>/${rel(`${stem}.bake`)}'), texture: ${assets(data.textures)}${hasLight ? `, light: ${assets(data.lightTextures!)}` : ""}${hasVolume ? `, volume: asset('<rel>/${rel(`${stem}.volume`)}')` : ""} } — asset() paths are relative to the scene file`))
241
+ return
242
+ }
243
+ if (lines.some((l) => /no bake module/.test(l))) {
244
+ throw new CliError("This desktop host has no bake module. Build packages/desktop (CREATOR_GL_LIGHTMAP is on there) and point LECODES_DESKTOP_EXE at build/lecodes-desktop.exe, or update: lecodes desktop update.")
245
+ }
246
+ if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
247
+ warnErr("The host ran to its frame cap without baking. Make sure the app calls Lightmap.load(...) after the static scene is built, and skips menus when Lightmap.baking is true (a title screen never reaches the scene). More load headroom: --frames <n>. Host output: --logs.")
248
+ throw new CliError("Nothing was baked.")
249
+ }
250
+
251
+ /** A 1x1 white UASTC KTX2 (the atlas' "no shadow, no occlusion" value; toktx v4.4.2, sRGB) + an empty
252
+ * bake — enough for the compiler to stage the files and for Lightmap.load to no-op with a "rebake"
253
+ * warning. */
254
+ const PLACEHOLDER_KTX2 = Buffer.from(
255
+ "q0tUWCAyMLsNChoKAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAEAAAAAAAAAaAAAACwAAACUAAAAfAAAAAAAAAAAAAAAAAAAAAAAAAAQAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAsAAAAAAAAAAIAKACmAQIAAwMAABAAAAAAAAAAAAB/AAAAAAAAAAAA/////xEAAABLVFhvcmllbnRhdGlvbgByAAAAACcAAABLVFh3cml0ZXIAdG9rdHggdjQuNC4yIC8gbGlia3R4IHY0LjQuMgAAMwAAAEtUWHdyaXRlclNjUGFyYW1zAC0tZW5jb2RlIHVhc3RjIC0tdWFzdGNfcXVhbGl0eSAwAAD3////H3zvAQAAAAAAAAAA",
256
+ "base64")
257
+
258
+ /** A 1x1x1 white light volume ("LVOL" v1, one lit cell): Lightmap.load treats a single-cell volume as "nothing baked". */
259
+ const placeholderVolume = (): Buffer => {
260
+ const b = Buffer.alloc(4 + 4 + 12 + 12 + 4 + 2)
261
+ b.write("LVOL", 0, "latin1")
262
+ b.writeUInt32LE(1, 4)
263
+ b.writeUInt32LE(1, 8); b.writeUInt32LE(1, 12); b.writeUInt32LE(1, 16)
264
+ b.writeFloatLE(0, 20); b.writeFloatLE(0, 24); b.writeFloatLE(0, 28)
265
+ b.writeFloatLE(1, 32)
266
+ b[36] = 255; b[37] = 255
267
+ return b
268
+ }
269
+
270
+ /** The files a scene may reference before the first bake exists: the .bake, page 0 of both atlases
271
+ * and the volume (later pages come from a bake that needed them). An EMPTY file counts as missing —
272
+ * a zero-byte KTX2 is what hand-made placeholders looked like, and the host cannot build a texture
273
+ * from it. */
274
+ const ensurePlaceholders = (outDir: string, stem: string): void => {
275
+ const missing = (f: string): boolean => !existsSync(f) || statSync(f).size === 0
276
+ const bake = join(outDir, `${stem}.bake`)
277
+ const ktx2 = join(outDir, pageFile(stem, 0, false))
278
+ const light = join(outDir, pageFile(stem, 0, true))
279
+ const volume = join(outDir, `${stem}.volume`)
280
+ if (missing(bake)) writeFileSync(bake, JSON.stringify({ version: 2, placeholder: true, size: 1, texel: 0, pages: 1, instances: [] }) + "\n")
281
+ if (missing(ktx2)) writeFileSync(ktx2, PLACEHOLDER_KTX2)
282
+ if (missing(light)) writeFileSync(light, PLACEHOLDER_KTX2)
283
+ if (missing(volume)) writeFileSync(volume, placeholderVolume())
284
+ }
285
+
286
+ export const lightmap = async (args: Args): Promise<void> => {
287
+ const sub = args._[0]
288
+ if (sub === "bake") return bake({ ...args, _: args._.slice(1) })
289
+ throw new CliError("Usage: lecodes lightmap bake [--out assets/lightmap] [--name lightmap] [--size 4096] [--pages 4] [--texel 0.02] [--sun-rays 32] [--ao-rays 64] [--ao-distance 2] [--bias 0.02] [--sun-angle 0.5] [--light-rays 16] [--light-radius 0.1] [--bounce 0..1] [--denoise 2] [--cell 0.5 | --no-volume] [--frames 900] [--timeout <ms>] [--entry main.ts] [--logs] (needs KTX-Software's toktx)")
290
+ }
@@ -0,0 +1,147 @@
1
+ import { existsSync } from "node:fs"
2
+ import { basename, join, resolve } from "node:path"
3
+ import { createProject, getCommits, getProject, type Project, type ProjectAsset } from "../api"
4
+ import { loadConfig, requireApiUrl, requireToken } from "../config"
5
+ import { ensureGitignore, writeDefaultIgnore } from "../ignore"
6
+ import { scanProject } from "../localFiles"
7
+ import { findProjectRoot, LECODES_DIR, writeManifest, type FileKind, type ManifestFile, type Manifest } from "../manifest"
8
+ import { buildPaths } from "../project"
9
+ import { computeServerDiff } from "../serverDiff"
10
+ import { materializeTypes, writeTsconfig } from "../types"
11
+ import { CliError, c, confirm, flagBool, flagStr, info, log, note, sha256, success, warn, type Args } from "../util"
12
+ import { parseUuid } from "./clone"
13
+ import { push } from "./push"
14
+
15
+ /*
16
+ * `lecodes link [dir]` — connect an existing local folder (typically one made by `lecodes init`) to
17
+ * the platform, so `status` / `push` / `pull` work on it. The mirror image of `clone`.
18
+ *
19
+ * Everything push needs is <root>/.lecodes/manifest.json, so linking is exactly: pick the remote
20
+ * project (create a fresh one, or adopt an existing one via --project), write that manifest, and
21
+ * refresh the types from the server now that there is one. Nothing is uploaded — the first
22
+ * `lecodes push` does that, so the user sees `status` before anything leaves the machine.
23
+ *
24
+ * Adopting a NON-EMPTY project is the one sharp edge: push is a set-tree, so it would delete
25
+ * whatever the server has and the local folder doesn't. The manifest is therefore built from the
26
+ * SERVER's tree (not empty), which makes `status`/`diff` truthful, and the exact server-side
27
+ * consequence is printed and confirmed before the link is written.
28
+ */
29
+
30
+ const kindOf = (type: ProjectAsset["type"]): FileKind => (type === "file" ? "resource" : type as FileKind)
31
+
32
+ /** Manifest file map describing the project's CURRENT server state, without downloading anything.
33
+ * Text assets carry their content, so their sha is exact and unchanged files show as unchanged;
34
+ * binaries aren't fetched here — an empty sha reads as "changed", so the first push re-uploads
35
+ * them. That costs bandwidth once and keeps linking a metadata-only operation. */
36
+ const manifestFromServer = (project: Project): Record<string, ManifestFile> => {
37
+ const pathById = buildPaths(project.assets)
38
+ const files: Record<string, ManifestFile> = {}
39
+ for (const asset of project.assets) {
40
+ if (asset.type === "folder") continue
41
+ files[pathById.get(asset.id)!] = {
42
+ id: asset.id,
43
+ type: kindOf(asset.type),
44
+ sha: asset.type === "file" ? "" : sha256(asset.text ?? ""),
45
+ }
46
+ }
47
+ return files
48
+ }
49
+
50
+ /** Show what a push from this folder would do to the project's server tree, and get a yes. */
51
+ const confirmAdoption = async (root: string, project: Project, files: Record<string, ManifestFile>, args: Args): Promise<boolean> => {
52
+ const count = Object.keys(files).length
53
+ if (count === 0) {
54
+ info(c.dim(`Project ${c.bold(project.name)} is empty on the server — nothing to overwrite.`))
55
+ return true
56
+ }
57
+
58
+ const scan = scanProject(root, { files } as Manifest)
59
+ const diff = computeServerDiff(project, scan)
60
+ if (diff.length === 0) {
61
+ info(c.dim(`Project ${c.bold(project.name)} (${count} file(s)) already matches this folder.`))
62
+ return true
63
+ }
64
+
65
+ warn(`${c.bold(project.name)} already has ${count} file(s). Your next push would change the server:`)
66
+ const label: Record<string, string> = { added: c.green(" create "), removed: c.red(" delete "), modified: c.yellow(" update ") }
67
+ for (const e of diff) log(label[e.status] + e.path + (e.binary ? c.dim(" (binary)") : ""))
68
+ log("")
69
+ note("`lecodes pull` after linking takes the server's version instead.")
70
+
71
+ if (flagBool(args, "y", "yes") || flagBool(args, "force") || !process.stdin.isTTY) return true
72
+ return confirm("Link this folder to that project?", false)
73
+ }
74
+
75
+ export const link = async (args: Args) => {
76
+ const config = loadConfig()
77
+ const apiUrl = requireApiUrl(config)
78
+ const token = requireToken(config)
79
+
80
+ const dirArg = args._[0]
81
+ const root = dirArg ? resolve(dirArg) : process.cwd()
82
+ if (!existsSync(root)) throw new CliError(`No such directory: ${root}`)
83
+
84
+ // Already linked? Re-linking silently would orphan the old project's history.
85
+ if (existsSync(join(root, LECODES_DIR, "manifest.json")) && !flagBool(args, "force")) {
86
+ throw new CliError("This folder is already linked (.lecodes/manifest.json exists) — use `lecodes push`, or `lecodes link --force` to re-link it to another project.")
87
+ }
88
+ // …or sitting inside a linked project, where a nested manifest would shadow the real root.
89
+ let enclosing: string | null = null
90
+ try { enclosing = findProjectRoot(root) } catch { /* not inside a project — the normal case */ }
91
+ if (enclosing && enclosing !== root) {
92
+ throw new CliError(`This folder is inside the linked project at ${enclosing} — push from there instead.`)
93
+ }
94
+
95
+ const projectRef = flagStr(args, "project")
96
+ let uuid: string
97
+ let name: string
98
+ let files: Record<string, ManifestFile>
99
+
100
+ if (projectRef) {
101
+ uuid = parseUuid(projectRef)
102
+ const project = await getProject(apiUrl, token, uuid)
103
+ if (project.role === "viewer") {
104
+ throw new CliError(`You have view-only access to ${project.name} — linking would give you a checkout you can't push.`)
105
+ }
106
+ files = manifestFromServer(project)
107
+ if (!(await confirmAdoption(root, project, files, args))) { info(c.dim("Aborted — nothing was linked.")); return }
108
+ name = project.name
109
+ } else {
110
+ name = flagStr(args, "name") ?? basename(root)
111
+ info(c.dim(`Creating project ${c.bold(name)}…`))
112
+ uuid = (await createProject(apiUrl, token, name, flagStr(args, "team"))).uuid
113
+ files = {} // fresh project: the whole folder is a pending add
114
+ }
115
+
116
+ const commits = await getCommits(apiUrl, token, uuid)
117
+ writeManifest(root, { uuid, apiUrl, name, baseHeadId: commits.headId, files })
118
+ log(`${c.green("linked")} ${c.bold(name)} ${c.dim(uuid)}`)
119
+
120
+ // Now that there IS a server, take its type surface — it matches the runtime the project will
121
+ // actually run on, unlike the CLI-bundled copy `lecodes init` wrote.
122
+ if (!flagBool(args, "no-types")) {
123
+ await materializeTypes(root, apiUrl)
124
+ writeTsconfig(root)
125
+ log(`${c.green("written")} ${LECODES_DIR}/types/ + tsconfig.json ${c.dim("(from the server)")}`)
126
+ }
127
+ writeDefaultIgnore(root)
128
+ if (ensureGitignore(root)) log(`${c.green("written")} .gitignore ${c.dim("(.lecodes/ is per-checkout state)")}`)
129
+
130
+ if (flagBool(args, "push")) {
131
+ log("")
132
+ if (root !== process.cwd()) process.chdir(root) // push works from the current project root
133
+ await push(args)
134
+ return
135
+ }
136
+
137
+ const pending = scanProject(root, { files } as Manifest).files.filter((f) => {
138
+ const known = files[f.path]
139
+ return !known || known.sha !== f.sha
140
+ }).length
141
+ log("")
142
+ success(`Connected to ${c.bold(name)}.`)
143
+ const cd = dirArg ? `cd ${dirArg} && ` : ""
144
+ log(` ${c.bold(cd + "lecodes status")} ${pending} file(s) would go up`)
145
+ log(` ${c.bold(cd + 'lecodes push -m "initial"')} push them as the first checkpoint`)
146
+ log(` ${c.dim("(add --publish to deploy it live in the same step)")}`)
147
+ }
@@ -0,0 +1,24 @@
1
+ import { getAccount } from "../api"
2
+ import { DEFAULT_API, loadConfig, normalizeApiUrl, saveConfig } from "../config"
3
+ import { browserLogin } from "../browserAuth"
4
+ import { flagStr, success, c, type Args } from "../util"
5
+
6
+ /*
7
+ * `lecodes login` — authorize this machine and store a personal access token in ~/.lecodes.
8
+ * - default: open the browser, sign in / authorize on le.codes, capture the token.
9
+ * - `--token <pat>`: store a token created in the web UI (headless / CI).
10
+ * - `--api <url>`: API origin (defaults to https://le.codes).
11
+ * - `--web <url>`: app origin for the browser flow (defaults to the API origin).
12
+ */
13
+ export const login = async (args: Args) => {
14
+ const config = loadConfig()
15
+ const apiUrl = normalizeApiUrl(flagStr(args, "api") ?? config.apiUrl ?? DEFAULT_API)
16
+ const webBase = normalizeApiUrl(flagStr(args, "web") ?? apiUrl)
17
+
18
+ const token = flagStr(args, "token") ?? await browserLogin(webBase)
19
+
20
+ // Verify the token works against the API before persisting it.
21
+ const account = await getAccount(apiUrl, token)
22
+ saveConfig({ apiUrl, token })
23
+ success(`Logged in as ${c.bold(account.email)} on ${apiUrl}`)
24
+ }