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,178 @@
1
+ import { createRequire } from "node:module"
2
+ import { execFileSync } from "node:child_process"
3
+ import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { extname, join, relative, resolve } from "node:path"
6
+ import { projectRootOf } from "./scene"
7
+ import { CliError, c, endProgress, flagBool, flagStr, log, note, progress, toKey, warnErr, type Args } from "../util"
8
+
9
+ /*
10
+ * `lecodes thumbs [path…]` — the scene editor's asset thumbnails, generated from the COMMAND LINE.
11
+ *
12
+ * The editor renders previews in a hidden browser host (3D assets need an engine), but image
13
+ * thumbnails need no engine at all — and a .ktx2 has no picture until something decodes it. This
14
+ * command fills the same cache the editor reads (`.lecodes/preview/<asset>.<hash>.png`, owned by
15
+ * lecodes-3d-editor's server/previewCache) with no browser in the loop, so opening the editor on a
16
+ * texture-heavy project shows a full grid immediately instead of rendering 40 thumbnails on the
17
+ * spot.
18
+ *
19
+ * Incremental by construction: the cache file name carries the first 10 hex of the SOURCE file's
20
+ * sha256, so an asset whose preview is already there is skipped after one hash — re-running over a
21
+ * big project is nearly free. `--force` ignores that.
22
+ *
23
+ * Decoders, in the order tried: sharp (through the optional `lecodes-assets` peer — handles webp),
24
+ * else jimp (bundled with this CLI: png / jpeg / bmp / gif / tiff). KTX2 goes through
25
+ * KTX-Software's `ktx extract` first (it is a GPU-compressed format; nothing in JS reads it), and
26
+ * the extracted PNG then takes the same resize path.
27
+ *
28
+ * NOT covered: models, materials and prefabs — those are RENDERED, which needs an engine. The
29
+ * editor still does them on demand (and `lecodes render --desktop` is the browserless renderer if
30
+ * that ever becomes a batch job).
31
+ */
32
+
33
+ const DEFAULT_SIZE = 256
34
+ const IMAGE_EXT = /\.(png|jpe?g|webp|bmp|gif|tiff?|ktx2)$/i
35
+ const RENDERED_EXT = /\.(glb|gltf|material\.ts|scene\.ts)$/i
36
+
37
+ /** KTX-Software's `ktx` CLI (v4.3+ has `extract`), resolved like lecodes-assets resolves toktx. */
38
+ const findKtx = (): string | null => {
39
+ const exe = process.platform === "win32" ? "ktx.exe" : "ktx"
40
+ const candidates = [
41
+ process.env.LECODES_KTX,
42
+ process.env.KTX_CLI,
43
+ process.env.KTX_TOKTX?.replace(/toktx(\.exe)?$/i, exe),
44
+ process.platform === "win32" ? join(process.env.LOCALAPPDATA ?? "", "Programs", "KTX-Software", "bin", exe) : null,
45
+ process.platform === "win32" ? join(process.env.ProgramFiles ?? "", "KTX-Software", "bin", exe) : null,
46
+ "/usr/local/bin/ktx",
47
+ "/opt/homebrew/bin/ktx",
48
+ ].filter((p): p is string => !!p)
49
+ for (const path of candidates) { if (existsSync(path)) return path }
50
+ try {
51
+ execFileSync(exe, [ "--version" ], { stdio: "ignore" })
52
+ return exe
53
+ } catch { return null }
54
+ }
55
+
56
+ type Resizer = (file: string, size: number) => Promise<Uint8Array>
57
+
58
+ /** sharp through the `lecodes-assets` peer (it declares it optional), or null when neither is
59
+ * installed — jimp then covers everything but webp. */
60
+ const sharpResizer = (): Resizer | null => {
61
+ try {
62
+ const req = createRequire(import.meta.url)
63
+ const sharp = createRequire(req.resolve("lecodes-assets/cli"))("sharp")
64
+ return async (file, size) =>
65
+ sharp(file).resize({ width: size, height: size, fit: "inside", withoutEnlargement: true }).png().toBuffer()
66
+ } catch { return null }
67
+ }
68
+
69
+ const jimpResizer = (): Resizer => async (file, size) => {
70
+ const { Jimp } = await import("jimp")
71
+ const img = await Jimp.read(file)
72
+ if (Math.max(img.width, img.height) > size) img.scaleToFit({ w: size, h: size })
73
+ return await img.getBuffer("image/png")
74
+ }
75
+
76
+ /** KTX2 → a temporary PNG (level 0, transcoded to RGBA8 when the payload is Basis-compressed). */
77
+ const ktxToPng = (ktx: string, file: string, out: string): void => {
78
+ const run = (args: string[]) => execFileSync(ktx, args, { stdio: [ "ignore", "ignore", "pipe" ], encoding: "utf8" })
79
+ try {
80
+ run([ "extract", "--transcode", "rgba8", "--level", "0", file, out ])
81
+ } catch (e: any) {
82
+ // Only Basis (ETC1S/UASTC) payloads are transcodable; a plain uncompressed KTX2 extracts as-is
83
+ run([ "extract", "--level", "0", file, out ])
84
+ if (!existsSync(out)) throw e
85
+ }
86
+ }
87
+
88
+ /** Every previewable file under a folder — the SAME sweep the editor's asset panel lists with
89
+ * (node_modules + dotfiles out, nothing else): what this command doesn't cover, the browser would
90
+ * end up rendering itself. */
91
+ const collect = (dir: string): string[] => {
92
+ const out: string[] = []
93
+ for (const name of readdirSync(dir)) {
94
+ if (name === "node_modules" || name.startsWith(".")) continue
95
+ const full = join(dir, name)
96
+ if (statSync(full).isDirectory()) out.push(...collect(full))
97
+ else if (IMAGE_EXT.test(name)) out.push(full)
98
+ }
99
+ return out
100
+ }
101
+
102
+ export const thumbs = async (args: Args) => {
103
+ const root = projectRootOf(process.cwd())
104
+
105
+ let sceneServer: typeof import("lecodes-3d-editor/server")
106
+ try {
107
+ sceneServer = await import("lecodes-3d-editor/server")
108
+ } catch {
109
+ throw new CliError("The scene editor isn't installed (it owns the preview cache). Run: npm install -g lecodes-3d-editor")
110
+ }
111
+
112
+ const size = Number(flagStr(args, "size")) || DEFAULT_SIZE
113
+ const force = flagBool(args, "force")
114
+
115
+ // Targets: the paths given (a file or a folder each), else the whole project.
116
+ let files: string[] = []
117
+ const rendered: string[] = []
118
+ if (args._.length > 0) {
119
+ for (const arg of args._) {
120
+ const abs = resolve(process.cwd(), arg)
121
+ if (!existsSync(abs)) throw new CliError(`No such file or folder: ${arg}`)
122
+ if (statSync(abs).isDirectory()) files.push(...collect(abs))
123
+ else if (IMAGE_EXT.test(abs)) files.push(abs)
124
+ else if (RENDERED_EXT.test(abs)) rendered.push(arg)
125
+ else throw new CliError(`Not a previewable asset: ${arg}`)
126
+ }
127
+ } else {
128
+ files = collect(root)
129
+ }
130
+ files = [ ...new Set(files) ]
131
+
132
+ if (files.length === 0 && rendered.length === 0) {
133
+ log("No images to thumbnail.")
134
+ return
135
+ }
136
+
137
+ const ktx = files.some((f) => f.toLowerCase().endsWith(".ktx2")) ? findKtx() : null
138
+ const sharp = sharpResizer()
139
+ const resize = sharp ?? jimpResizer()
140
+ const tmp = mkdtempSync(join(tmpdir(), "lecodes-thumbs-"))
141
+
142
+ let made = 0, fresh = 0
143
+ const skipped: string[] = []
144
+ try {
145
+ for (const [ i, file ] of files.entries()) {
146
+ const asset = "./" + toKey(relative(root, file))
147
+ progress(i, files.length, asset)
148
+ const hash = sceneServer.hashAsset(root, asset)
149
+ if (!hash) continue
150
+ if (!force && sceneServer.previewIsCurrent(root, asset, hash)) { fresh++; continue }
151
+
152
+ const ext = extname(file).toLowerCase()
153
+ if (ext === ".webp" && !sharp) { skipped.push(`${asset} (webp needs sharp — npm install -g lecodes-assets)`); continue }
154
+ try {
155
+ let source = file
156
+ if (ext === ".ktx2") {
157
+ if (!ktx) { skipped.push(`${asset} (KTX2 needs KTX-Software's \`ktx\` — set LECODES_KTX or put it on PATH)`); continue }
158
+ source = join(tmp, `${i}.png`)
159
+ ktxToPng(ktx, file, source)
160
+ }
161
+ sceneServer.writePreviewFile(root, asset, await resize(source, size))
162
+ made++
163
+ } catch (e: any) {
164
+ skipped.push(`${asset} (${(e?.message ?? String(e)).split("\n")[0]})`)
165
+ }
166
+ }
167
+ } finally {
168
+ endProgress()
169
+ rmSync(tmp, { recursive: true, force: true })
170
+ }
171
+
172
+ log(`${c.bold(String(made))} generated · ${fresh} already current${skipped.length > 0 ? ` · ${skipped.length} skipped` : ""}`)
173
+ for (const s of skipped) warnErr(s)
174
+ if (rendered.length > 0) {
175
+ warnErr(`Models, materials and prefabs are RENDERED, not decoded — the scene editor does those: ${rendered.join(", ")}`)
176
+ }
177
+ note(`Cache: ${join(root, ".lecodes", "preview")} (re-runs skip everything unchanged)`)
178
+ }
@@ -0,0 +1,87 @@
1
+ import { resolve } from "node:path"
2
+ import { getTypes } from "../api"
3
+ import { DEFAULT_API, loadConfig, normalizeApiUrl } from "../config"
4
+ import { findProjectRoot, LECODES_DIR } from "../manifest"
5
+ import {
6
+ bundleHash, readLocalTypeBundle, readProjectTypeBundle, writeTsconfig, writeTypeBundle,
7
+ type TypeBundle,
8
+ } from "../types"
9
+ import { CliError, c, flagBool, flagStr, log, note, success, warn, type Args } from "../util"
10
+
11
+ /*
12
+ * The types step of `lecodes update` (formerly `lecodes types [dir]`) — re-materialize the IDE type surface (<root>/.lecodes/types/ + tsconfig).
13
+ *
14
+ * Every project gets these types once, at scaffold time: `clone`/`create`/`pull` fetch them from
15
+ * GET /api/types (the deployed runtime's surface), while `init` and `design` — which run with no
16
+ * server and no login — write the bundle vendored into the CLI at publish time. Neither source
17
+ * refreshes on its own, so a local project's types age with the CLI binary that made it. This
18
+ * command is the refresh: server by default (newest SDK the platform actually runs), `--local` for
19
+ * the CLI's own copy, `--check` to only report drift (non-zero exit when stale).
20
+ *
21
+ * IDE-only: the compiler reads the SDK sources vendored in runtime/sdk, never .lecodes/types. When
22
+ * the two sources disagree the command says so — types promising APIs this CLI can't compile is
23
+ * exactly the confusing case worth naming out loud.
24
+ */
25
+
26
+ const label = (bundle: TypeBundle) => `${Object.keys(bundle).length} file(s)`
27
+
28
+ export const types = async (args: Args) => {
29
+ const dirArg = args._[0]
30
+ const root = dirArg ? resolve(dirArg) : (() => {
31
+ try { return findProjectRoot(process.cwd()) } catch { return process.cwd() }
32
+ })()
33
+
34
+ const useLocal = flagBool(args, "local")
35
+ const checkOnly = flagBool(args, "check")
36
+ const local = readLocalTypeBundle()
37
+
38
+ // ---- pick the source ----
39
+ let bundle: TypeBundle | null = null
40
+ let source = ""
41
+ if (useLocal) {
42
+ if (!local) throw new CliError("This CLI ships no vendored type bundle — drop --local to fetch them from the server.")
43
+ bundle = local
44
+ source = "the CLI's bundled SDK"
45
+ } else {
46
+ const apiUrl = normalizeApiUrl(flagStr(args, "api") ?? loadConfig().apiUrl ?? DEFAULT_API)
47
+ try {
48
+ bundle = await getTypes(apiUrl)
49
+ source = apiUrl
50
+ } catch (e) {
51
+ if (!local || checkOnly) throw e
52
+ warn(`${(e as Error).message}`)
53
+ bundle = local
54
+ source = "the CLI's bundled SDK (server unreachable)"
55
+ }
56
+ }
57
+ log(`${c.green("source")} ${source} — ${label(bundle)}`)
58
+
59
+ // ---- report drift, write unless --check ----
60
+ const onDisk = readProjectTypeBundle(root)
61
+ const same = bundleHash(onDisk) === bundleHash(bundle)
62
+ if (checkOnly) {
63
+ if (Object.keys(onDisk).length === 0) throw new CliError(`No types in ${LECODES_DIR}/types/ — run \`lecodes update\`.`)
64
+ if (!same) throw new CliError(`Types are out of date (${label(onDisk)} on disk) — run \`lecodes update\`.`)
65
+ success(`Types are up to date (${label(onDisk)}).`)
66
+ return
67
+ }
68
+
69
+ const stats = writeTypeBundle(root, bundle)
70
+ writeTsconfig(root)
71
+ const changes = [
72
+ stats.added ? `${stats.added} added` : null,
73
+ stats.updated ? `${stats.updated} updated` : null,
74
+ stats.removed ? `${stats.removed} removed` : null,
75
+ ].filter(Boolean)
76
+ log(`${c.green("written")} ${LECODES_DIR}/types/ + tsconfig.json ${c.dim(changes.length ? `(${changes.join(", ")})` : "(already current)")}`)
77
+
78
+ // The compiler uses the SDK vendored in this CLI, not these declarations — worth saying when the
79
+ // server's surface is a different one, since that gap shows up as "autocompletes but won't build".
80
+ if (!useLocal && local && bundleHash(local) !== bundleHash(bundle)) {
81
+ warn("The server's SDK surface differs from the one this CLI compiles with — if something autocompletes but fails to compile, update the CLI (`npm install -g lecodes-cli@latest`) or re-run with --local.")
82
+ }
83
+
84
+ log("")
85
+ success(stats.added + stats.updated + stats.removed > 0 ? "Types synced." : "Types already current.")
86
+ if (!dirArg) note("Reload the TypeScript server in your editor to pick them up.")
87
+ }
@@ -0,0 +1,190 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, readdirSync } from "node:fs"
3
+ import { homedir } from "node:os"
4
+ import { dirname, join, resolve } from "node:path"
5
+ import pkg from "../../package.json"
6
+ import { CliError, c, flagBool, log, note, type Args } from "../util"
7
+ import { OPTIONAL_PEERS, peerVersion } from "../peers"
8
+ import { updateDesktop } from "../desktopRenderer"
9
+ import { updateMatc } from "../matcTool"
10
+ import { types } from "./types"
11
+ import { shaders } from "./shaders"
12
+ import { app } from "./app"
13
+
14
+ /*
15
+ * `lecodes update` — one command that moves EVERYTHING a project depends on to its newest published
16
+ * state, in the order the pieces depend on each other:
17
+ *
18
+ * 1. desktop the LeCodes Desktop host `desktop run` / `render --desktop` use (= `desktop update`)
19
+ * 2. matc Filament's material compiler for local shader compiles (= `shaders update`)
20
+ * 3. shaders recompile the project's .mat files with that matc (= `shaders compile`)
21
+ * 4. types the IDE types (.lecodes/types + tsconfig) from the server (= `types`)
22
+ * 5. shells ios/ + android/ pins → newest SDK release + plugin tags, re-sync (= `app update`)
23
+ * 6. cli the GLOBAL npm installs — every installed optional peer (lecodes-renderer,
24
+ * lecodes-design, lecodes-3d-editor, lecodes-assets) and the CLI itself that is behind
25
+ * npm's latest: `npm install -g …@latest` (peers first, the CLI last; the standalone
26
+ * binary can't come from npm — it says so and does the peers only)
27
+ *
28
+ * Every step is the existing subcommand, so nothing here can drift from what the individual
29
+ * commands do; this only sequences them and keeps going when one fails (the summary says which).
30
+ * Steps that the environment pins are SKIPPED, not overridden: LECODES_DESKTOP_EXE means a local
31
+ * host build (engine work — the cache is irrelevant), LECODES_MATC a local matc; the project steps
32
+ * need a project (a dir with app.json / .lecodes above the cwd) and the shell step a shell folder.
33
+ *
34
+ * Flags: --local (types from the SDK bundled in this CLI instead of the server), --no-shells (leave
35
+ * ios/android pins alone — `app update` re-syncs, which compiles the bundle and, for server projects,
36
+ * needs a login), --check (the npm step only REPORTS what is behind and prints the install line),
37
+ * --no-cli (skip the npm step — offline / air-gapped machines).
38
+ *
39
+ * `lecodes types` used to be its own command; the types step here replaced it (2026-09-04).
40
+ */
41
+
42
+ /** The nearest project at or above `start`: a dir with app.json, or a .lecodes/ that holds a project's
43
+ * manifest or types. NOT the bare `.lecodes/` marker projectEnv uses — the CLI's own config lives in
44
+ * ~/.lecodes, so that rule would turn any folder under the home dir into a "project" and the types
45
+ * step would scaffold .lecodes/types + tsconfig.json into it. */
46
+ const findProjectDir = (start: string): string | null => {
47
+ const home = resolve(homedir())
48
+ let dir = resolve(start)
49
+ while (true) {
50
+ if (dir !== home && (existsSync(join(dir, "app.json")) || existsSync(join(dir, ".lecodes", "manifest.json")) || existsSync(join(dir, ".lecodes", "types")))) return dir
51
+ const parent = dirname(dir)
52
+ if (parent === dir) return null
53
+ dir = parent
54
+ }
55
+ }
56
+
57
+ type Outcome = "updated" | "ok" | "skipped" | "failed"
58
+ type Step = { name: string, outcome: Outcome, detail?: string }
59
+
60
+ const NPM = "https://registry.npmjs.org"
61
+
62
+ /** `latest` dist-tag of an npm package, or null when npm is unreachable / the package is unknown. */
63
+ const npmLatest = async (name: string): Promise<string | null> => {
64
+ try {
65
+ const res = await fetch(`${NPM}/${name}/latest`, { signal: AbortSignal.timeout(4000) })
66
+ if (!res.ok) return null
67
+ const json = await res.json() as { version?: string }
68
+ return json.version ?? null
69
+ } catch {
70
+ return null
71
+ }
72
+ }
73
+
74
+ const semverNewer = (a: string, b: string): number => {
75
+ const pa = a.split(/[.-]/).map(Number), pb = b.split(/[.-]/).map(Number)
76
+ for (let i = 0; i < 3; i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d }
77
+ return 0
78
+ }
79
+
80
+ /** Running straight from the repo checkout (src/commands/update.ts), not a built dist/binary. */
81
+ const FROM_SOURCE = /[\\/]src[\\/]commands[\\/]/.test(import.meta.url)
82
+ /** The standalone release build: the bundle + chisel sit next to the executable (same test as index.ts). */
83
+ const STANDALONE = existsSync(join(dirname(process.execPath), "runtime")) &&
84
+ existsSync(join(dirname(process.execPath), process.platform === "win32" ? "chisel.exe" : "chisel"))
85
+
86
+ export const update = async (args: Args) => {
87
+ const steps: Step[] = []
88
+ const heading = (name: string, what: string) => log(`\n${c.bold(name)} ${c.dim(what)}`)
89
+ const run = async (name: string, what: string, fn: () => Promise<Outcome | [Outcome, string]>) => {
90
+ heading(name, what)
91
+ try {
92
+ const r = await fn()
93
+ const [outcome, detail] = Array.isArray(r) ? r : [r, undefined]
94
+ steps.push({ name, outcome, detail })
95
+ if (outcome === "skipped" && detail) note(`skipped — ${detail}`)
96
+ } catch (e) {
97
+ const msg = e instanceof CliError ? e.message : e instanceof Error ? e.message : String(e)
98
+ log(c.red("✗ ") + msg)
99
+ steps.push({ name, outcome: "failed", detail: msg })
100
+ }
101
+ }
102
+
103
+ const root = findProjectDir(process.cwd())
104
+
105
+ // 1. desktop host
106
+ await run("desktop", "LeCodes Desktop host (desktop run / render --desktop)", async () => {
107
+ if (process.env.LECODES_DESKTOP_EXE) return ["skipped", `LECODES_DESKTOP_EXE=${process.env.LECODES_DESKTOP_EXE} (a local build; \`lecodes desktop update\` refreshes the cache anyway)`]
108
+ await updateDesktop()
109
+ return "ok"
110
+ })
111
+
112
+ // 2. matc
113
+ await run("matc", "Filament material compiler for local shader compiles", async () => {
114
+ if (process.env.LECODES_MATC) return ["skipped", `LECODES_MATC=${process.env.LECODES_MATC} (a local matc)`]
115
+ await updateMatc()
116
+ return "ok"
117
+ })
118
+
119
+ // 3. shaders (project)
120
+ await run("shaders", "recompile the project's .mat shaders with the current matc", async () => {
121
+ if (!root) return ["skipped", "not inside a project"]
122
+ await shaders({ _: ["compile"], flags: {} })
123
+ return "ok"
124
+ })
125
+
126
+ // 4. types (project)
127
+ await run("types", flagBool(args, "local") ? "IDE types from the SDK bundled in this CLI" : "IDE types from the server", async () => {
128
+ if (!root) return ["skipped", "not inside a project"]
129
+ await types({ _: [], flags: flagBool(args, "local") ? { local: true } : {} })
130
+ return "ok"
131
+ })
132
+
133
+ // 5. native shells (project)
134
+ await run("shells", "ios/ + android/ pins → newest SDK release + plugin tags, then re-sync", async () => {
135
+ if (!root) return ["skipped", "not inside a project"]
136
+ if (flagBool(args, "no-shells")) return ["skipped", "--no-shells"]
137
+ const present = ["ios", "android"].filter((d) => existsSync(join(root!, d)) && readdirSync(join(root!, d)).length > 0)
138
+ if (present.length === 0) return ["skipped", "no ios/ or android/ shell in this project (`lecodes app init ios|android`)"]
139
+ await app({ _: ["update"], flags: {} })
140
+ return "ok"
141
+ })
142
+
143
+ // 6. this CLI + its optional peers — the GLOBAL npm installs (`npm install -g`, how the CLI and its
144
+ // peers are installed): every installed one that is behind npm's latest moves forward. The peers go
145
+ // first, the CLI itself last — replacing the running CLI's files is fine (this process already
146
+ // holds its code), but its new version is what the next invocation runs, so nothing after it here.
147
+ // Peers that are NOT installed stay that way: they're optional, installing one is a choice.
148
+ await run("cli", "lecodes-cli + its optional peers (global npm installs)", async () => {
149
+ if (flagBool(args, "no-cli")) return ["skipped", "--no-cli"]
150
+ if (FROM_SOURCE) return ["skipped", "running from the repo checkout — the CLI and its peers are the repo's own"]
151
+ const rows: { name: string, installed: string | null, latest: string | null }[] = []
152
+ for (const [name, probe] of OPTIONAL_PEERS) {
153
+ const installed = peerVersion(name, probe)
154
+ rows.push({ name, installed, latest: installed ? await npmLatest(name) : null })
155
+ }
156
+ rows.push({ name: "lecodes-cli", installed: pkg.version, latest: await npmLatest("lecodes-cli") })
157
+ if (rows.every((r) => r.latest === null)) return ["skipped", "npm unreachable"]
158
+ const behind = rows.filter((r) => r.installed && r.latest && semverNewer(r.latest, r.installed) > 0)
159
+ for (const r of rows) {
160
+ const state = !r.installed ? c.dim("not installed (optional — npm install -g " + r.name + ")")
161
+ : !r.latest ? c.dim("(npm: unknown)")
162
+ : behind.includes(r) ? c.yellow("→ " + r.latest)
163
+ : c.dim("(up to date)")
164
+ log(" " + r.name.padEnd(18) + " " + (r.installed ?? "").padEnd(8) + " " + state)
165
+ }
166
+ if (behind.length === 0) return "ok"
167
+ const specs = behind.map((r) => r.name + "@latest")
168
+ if (flagBool(args, "check")) return ["updated", behind.length + " behind — run: npm install -g " + specs.join(" ")]
169
+ if (STANDALONE && behind.some((r) => r.name === "lecodes-cli")) {
170
+ note("this is the standalone build — the CLI updates by re-installing the release archive, not through npm; peers still go through npm")
171
+ }
172
+ const toInstall = STANDALONE ? specs.filter((s) => !s.startsWith("lecodes-cli@")) : specs
173
+ if (toInstall.length === 0) return ["skipped", "only the standalone CLI itself is behind — download the release"]
174
+ log(" " + c.dim("$") + " npm install -g " + toInstall.join(" "))
175
+ const r = spawnSync("npm", ["install", "-g", ...toInstall], { stdio: "inherit", shell: true })
176
+ if (r.status !== 0) throw new CliError("npm install -g exited with " + (r.status ?? "a signal"))
177
+ const names = toInstall.map((s) => s.replace("@latest", "")).join(", ")
178
+ const cliToo = toInstall.some((s) => s.startsWith("lecodes-cli@"))
179
+ return ["updated", names + " → latest" + (cliToo ? " (the new CLI runs from your next command)" : "")]
180
+ })
181
+
182
+ // Summary — one line per step so a scrolled-away failure can't hide.
183
+ log(`\n${c.bold("Summary")}`)
184
+ const width = Math.max(...steps.map((s) => s.name.length))
185
+ for (const s of steps) {
186
+ const mark = s.outcome === "failed" ? c.red("✗") : s.outcome === "skipped" ? c.dim("–") : c.green("✓")
187
+ log(` ${mark} ${s.name.padEnd(width)} ${s.outcome === "failed" ? c.red(s.detail ?? "failed") : s.outcome === "skipped" ? c.dim(s.detail ?? "skipped") : s.detail ?? s.outcome}`)
188
+ }
189
+ if (steps.some((s) => s.outcome === "failed")) process.exitCode = 1
190
+ }
@@ -0,0 +1,214 @@
1
+ import { createHash } from "node:crypto"
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
+ import { homedir } from "node:os"
4
+ import { dirname, join } from "node:path"
5
+ import { applyAssetIconMacro, buildIconsHeaderValue, collectVendoredIcons, iconNotFoundError, ICON_ID, type AssetIconEntry } from "lecodes-sdk/compile"
6
+
7
+ /*
8
+ * The CLI side of the `assetIcon('pack:name')` compile macro. The scan/rewrite core lives in the
9
+ * SDK (sdk/compile assetIconMacro — shared with the backend); this wrapper decides where the SVG
10
+ * bodies come from per compile mode:
11
+ *
12
+ * design — `lecodes design` screens: icons vendor INTO the design folder
13
+ * (assets/icons/<pack>/<name>.svg + icons.lock.json + ICON-NOTICES.md), so the pushed
14
+ * design renders offline (the platform board resolves vendored svgs first, its own
15
+ * cache/registry for the rest) and attribution ships in the user's repo.
16
+ * app — `lecodes compile` / `dev` / `render` / `test`: icons resolve from the user-level cache
17
+ * (~/.lecodes/cache/icons/<pack>/<name>.svg), fetching misses from the registry. Nothing
18
+ * is written into the project — the project tree stays exactly what the user wrote.
19
+ * No lock: registry `/v1` ids are immutable (a redesigned glyph is a new name or a /v2),
20
+ * so a cached SVG is valid forever. Attribution rides the bundle's `// icons:` header
21
+ * (packs inlined) + the platform's attribution surface, not an in-repo notice.
22
+ */
23
+
24
+ /** Default registry base — per-icon SVGs at `<base>/<pack>/<name>.svg`, licenses at `<base>/licenses.json`.
25
+ * A local directory (with the same layout) works too, for tests. Override with LECODES_ICON_REGISTRY. */
26
+ const DEFAULT_REGISTRY = process.env.LECODES_ICON_REGISTRY?.trim() || "https://icon-registry.jt3.ru/v1"
27
+
28
+ // ---- registry access ----------------------------------------------------------------------------
29
+
30
+ type LicenseEntry = { spdx: string, title: string, url: string, attribution: string | null, modifiedNotice: boolean }
31
+
32
+ const isHttp = (s: string) => /^https?:/i.test(s)
33
+ const sha12 = (s: string) => createHash("sha256").update(s).digest("hex").slice(0, 12)
34
+
35
+ const readText = async (registry: string, rel: string): Promise<string | null> => {
36
+ if (isHttp(registry)) {
37
+ // Network failure reads as absent — callers turn a fully-unreachable registry into a clear error.
38
+ try {
39
+ const res = await fetch(`${registry}/${rel}`)
40
+ return res.ok ? await res.text() : null
41
+ } catch { return null }
42
+ }
43
+ try { return readFileSync(join(registry, rel), "utf8") } catch { return null }
44
+ }
45
+
46
+ const safeRead = (path: string): string | null => { try { return readFileSync(path, "utf8") } catch { return null } }
47
+
48
+ const writeIfChanged = (path: string, text: string): boolean => {
49
+ const current = existsSync(path) ? safeRead(path) : null
50
+ if (current === text) return false
51
+ writeFileSync(path, text)
52
+ return true
53
+ }
54
+
55
+ /** Fetch one icon's SVG; a miss throws the legible SDK-built error ("did you mean …" from the
56
+ * pack's meta.json, or the available-pack list when the pack itself is unknown). */
57
+ const fetchIcon = async (registry: string, pack: string, name: string): Promise<string> => {
58
+ const svg = await readText(registry, `${pack}/${name}.svg`)
59
+ if (svg !== null) return svg
60
+ throw await iconNotFoundError(pack, name, (rel) => readText(registry, rel), registry)
61
+ }
62
+
63
+ const loadLicenses = async (registry: string): Promise<Record<string, LicenseEntry>> => {
64
+ const raw = await readText(registry, "licenses.json")
65
+ if (!raw) throw new Error(`assetIcon: can't reach the icon registry (${registry}). Check your connection, or set LECODES_ICON_REGISTRY.`)
66
+ return JSON.parse(raw) as Record<string, LicenseEntry>
67
+ }
68
+
69
+ // ---- design mode: vendoring (registry → design/assets/icons + lock + notices) --------------------
70
+
71
+ type LockIcon = { pack: string, name: string, hash: string, spdx: string, path: string }
72
+ type Lock = { registry: string, icons: Record<string, LockIcon>, packs: Record<string, LicenseEntry> }
73
+
74
+ const readLock = (path: string): Lock | null => {
75
+ try {
76
+ const lock = JSON.parse(readFileSync(path, "utf8")) as Lock
77
+ if (lock && typeof lock === "object" && lock.icons && lock.packs) return lock
78
+ } catch { /* absent or corrupt → start fresh */ }
79
+ return null
80
+ }
81
+
82
+ /** A stable serialization: keys sorted, no timestamp — same inputs produce byte-identical output. */
83
+ const serializeLock = (lock: Lock): string => {
84
+ const sortKeys = <T>(rec: Record<string, T>): Record<string, T> =>
85
+ Object.fromEntries(Object.keys(rec).sort().map((k) => [k, rec[k]]))
86
+ return JSON.stringify({ registry: lock.registry, icons: sortKeys(lock.icons), packs: sortKeys(lock.packs) }, null, 2) + "\n"
87
+ }
88
+
89
+ const renderNotices = (lock: Lock): string => {
90
+ const packs = [...new Set(Object.values(lock.icons).map((i) => i.pack))].sort()
91
+ const lines = [
92
+ "# Icon attribution & notices",
93
+ "",
94
+ "Icons vendored from the icon-registry via `assetIcon(...)`. Keep this file in your repo / product.",
95
+ "",
96
+ ]
97
+ for (const p of packs) {
98
+ const l = lock.packs[p]
99
+ if (!l) continue
100
+ let note = `- **${p}** — ${l.spdx}${l.url ? ` — ${l.url}` : ""}`
101
+ if (l.attribution) note += `\n - **Attribution required:** ${l.attribution}`
102
+ if (l.modifiedNotice) note += `\n - ${l.spdx}: state any modifications if you alter the glyphs.`
103
+ lines.push(note)
104
+ }
105
+ lines.push("")
106
+ return lines.join("\n")
107
+ }
108
+
109
+ /** Resolve `ids` to their SVG bodies, vendoring any not already on disk. Reads cached icons off disk
110
+ * (no network) and only fetches the misses; then keeps icons.lock.json + ICON-NOTICES.md in sync.
111
+ * Returns id → svg. Throws a clear compile error on an unknown pack / missing icon. */
112
+ export const vendorIcons = async (
113
+ designDir: string,
114
+ ids: string[],
115
+ opts: { registry?: string } = {},
116
+ ): Promise<Map<string, string>> => {
117
+ const registry = (opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "")
118
+ const lockPath = join(designDir, "icons.lock.json")
119
+ const lock: Lock = readLock(lockPath) ?? { registry, icons: {}, packs: {} }
120
+ const svgById = new Map<string, string>()
121
+
122
+ // Serve from the on-disk vendor cache where we can; collect the rest.
123
+ const missing: string[] = []
124
+ for (const id of ids) {
125
+ const li = lock.icons[id]
126
+ const cached = li && safeRead(join(designDir, li.path))
127
+ if (cached) svgById.set(id, cached)
128
+ else missing.push(id)
129
+ }
130
+
131
+ if (missing.length) {
132
+ const licenses = await loadLicenses(registry)
133
+
134
+ for (const id of missing) {
135
+ const m = ICON_ID.exec(id)! // scanner validated the shape already
136
+ const [, pack, name] = m
137
+ const lic = licenses[pack]
138
+ if (!lic) throw new Error(`assetIcon: unknown icon pack "${pack}" (in "${id}"). Available packs: ${Object.keys(licenses).sort().join(", ")}.`)
139
+ const svg = await fetchIcon(registry, pack, name)
140
+
141
+ const rel = `assets/icons/${pack}/${name}.svg`
142
+ const abs = join(designDir, rel)
143
+ mkdirSync(dirname(abs), { recursive: true })
144
+ writeFileSync(abs, svg)
145
+ lock.icons[id] = { pack, name, hash: sha12(svg), spdx: lic.spdx, path: rel }
146
+ lock.packs[pack] = { spdx: lic.spdx, title: lic.title, url: lic.url, attribution: lic.attribution, modifiedNotice: lic.modifiedNotice }
147
+ svgById.set(id, svg)
148
+ }
149
+ lock.registry = registry
150
+ writeIfChanged(lockPath, serializeLock(lock))
151
+ }
152
+
153
+ // Attribution is fail-closed: notices always reflect the vendored packs (write throws → build fails).
154
+ if (Object.keys(lock.icons).length) writeIfChanged(join(designDir, "ICON-NOTICES.md"), renderNotices(lock))
155
+
156
+ return svgById
157
+ }
158
+
159
+ /** The design-screen compile pass: resolve + vendor every `assetIcon(...)` into the design folder
160
+ * and rewrite the calls inline (mutates `entries[].text`). A no-op when nothing uses `assetIcon`. */
161
+ export const applyAssetIcons = async (
162
+ entries: AssetIconEntry[],
163
+ designDir: string,
164
+ opts: { registry?: string } = {},
165
+ ): Promise<void> => {
166
+ await applyAssetIconMacro(entries, (ids) => vendorIcons(designDir, ids, opts))
167
+ }
168
+
169
+ // ---- app mode: the user-level icon cache ----------------------------------------------------------
170
+
171
+ /** `~/.lecodes/cache/icons` — shared by every project on the machine (icons are project-independent
172
+ * and immutable per id, so one fetch ever). Sits next to the CLI's other user-level state (config.ts). */
173
+ const defaultCacheDir = () => join(homedir(), ".lecodes", "cache", "icons")
174
+
175
+ /** Resolve `ids` via the user-level cache, fetching misses from the registry. No lock, no notices,
176
+ * no project writes (see the header comment). Returns id → svg. */
177
+ export const cacheIcons = async (
178
+ ids: string[],
179
+ opts: { registry?: string, cacheDir?: string } = {},
180
+ ): Promise<Map<string, string>> => {
181
+ const registry = (opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "")
182
+ const cacheDir = opts.cacheDir ?? defaultCacheDir()
183
+ const svgById = new Map<string, string>()
184
+
185
+ for (const id of ids) {
186
+ const [, pack, name] = ICON_ID.exec(id)! // scanner validated the shape already
187
+ const cachePath = join(cacheDir, pack, `${name}.svg`)
188
+ const cached = safeRead(cachePath)
189
+ if (cached) { svgById.set(id, cached); continue }
190
+
191
+ const svg = await fetchIcon(registry, pack, name)
192
+ mkdirSync(dirname(cachePath), { recursive: true })
193
+ writeFileSync(cachePath, svg)
194
+ svgById.set(id, svg)
195
+ }
196
+ return svgById
197
+ }
198
+
199
+ /** The app-compile pass (`compile`/`dev`/`render`/`test`): resolve every `assetIcon(...)` — icons
200
+ * the design flow already vendored into the tree (`…assets/icons/**`) first, then the user-level
201
+ * cache — and rewrite the calls inline (mutates `entries[].text`). Returns the `// icons:` header
202
+ * payload (distinct packs), or undefined when the project uses no icons. */
203
+ export const applyAppAssetIcons = async (
204
+ entries: AssetIconEntry[],
205
+ opts: { registry?: string, cacheDir?: string } = {},
206
+ ): Promise<string | undefined> => {
207
+ const ids = await applyAssetIconMacro(entries, async (wanted) => {
208
+ const vendored = collectVendoredIcons(entries)
209
+ const misses = wanted.filter((id) => !vendored.has(id))
210
+ const fetched = misses.length ? await cacheIcons(misses, opts) : new Map<string, string>()
211
+ return new Map(wanted.map((id) => [id, vendored.get(id) ?? fetched.get(id)!]))
212
+ })
213
+ return ids.length ? buildIconsHeaderValue(ids) : undefined
214
+ }