lecodes-cli 0.20.2 → 2.0.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 (154) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +11052 -7592
  3. package/package.json +14 -8
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap.mat +117 -138
  7. package/runtime/materials/particles-quad.mat +6 -2
  8. package/runtime/materials/terrain-lightmap.mat +123 -0
  9. package/runtime/materials/terrain.mat +36 -55
  10. package/runtime/sdk-types.json +1 -1
  11. package/runtime/web/VERSION.json +5 -0
  12. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  13. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  14. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  15. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  16. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  17. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  18. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  19. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  20. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  21. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  22. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  23. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  24. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  25. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  26. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  27. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  28. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  29. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  30. package/runtime/web/index.html +36 -0
  31. package/runtime/web/player.js +21 -0
  32. package/runtime/web-page/index.html +41 -0
  33. package/runtime/web-page/page.css +1 -0
  34. package/runtime/web-page/page.js +31 -0
  35. package/src/api.ts +1 -302
  36. package/src/cli/args.ts +37 -0
  37. package/src/cli/command.ts +113 -0
  38. package/src/cli/errors.ts +13 -0
  39. package/src/cli/help.ts +158 -0
  40. package/src/cli/index.ts +7 -0
  41. package/src/cli/output.ts +102 -0
  42. package/src/cli/run.ts +106 -0
  43. package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
  44. package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
  45. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
  46. package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
  47. package/src/commands/{app.ts → app/index.ts} +400 -290
  48. package/src/commands/{appShared.ts → app/shared.ts} +43 -44
  49. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
  50. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  51. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
  52. package/src/commands/assets.ts +23 -21
  53. package/src/commands/clone.ts +48 -51
  54. package/src/commands/compile.ts +76 -89
  55. package/src/commands/create.ts +46 -47
  56. package/src/commands/design/comments.ts +361 -0
  57. package/src/commands/design/context.ts +101 -0
  58. package/src/commands/design/index.ts +391 -0
  59. package/src/commands/design/snapshot.ts +134 -0
  60. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  61. package/src/commands/desktop.ts +94 -82
  62. package/src/commands/dev.ts +260 -182
  63. package/src/commands/diff.ts +39 -51
  64. package/src/commands/index.ts +64 -0
  65. package/src/commands/{init.ts → init/index.ts} +195 -191
  66. package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
  67. package/src/commands/install.ts +80 -67
  68. package/src/commands/lightmap.ts +543 -252
  69. package/src/commands/link.ts +87 -94
  70. package/src/commands/login.ts +25 -23
  71. package/src/commands/navmesh.ts +169 -139
  72. package/src/commands/plugin.ts +46 -0
  73. package/src/commands/pn.ts +201 -244
  74. package/src/commands/pull.ts +49 -56
  75. package/src/commands/push.ts +48 -54
  76. package/src/commands/render.ts +176 -111
  77. package/src/commands/scene.ts +63 -55
  78. package/src/commands/serve.ts +58 -0
  79. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  80. package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
  81. package/src/commands/shared.ts +79 -0
  82. package/src/commands/status.ts +22 -26
  83. package/src/commands/test.ts +94 -63
  84. package/src/commands/thumbs.ts +185 -175
  85. package/src/commands/update/index.ts +226 -0
  86. package/src/commands/{types.ts → update/types.ts} +31 -22
  87. package/src/compile/collect.ts +5 -5
  88. package/src/compile/collectLocal.ts +3 -3
  89. package/src/compile/designCompile.ts +4 -4
  90. package/src/compile/headlessBundle.ts +16 -12
  91. package/src/compile/nativeStack.ts +1 -1
  92. package/src/compile/projectCompile.ts +12 -4
  93. package/src/compile/sceneCompile.ts +14 -26
  94. package/src/compile/screenEntry.ts +123 -127
  95. package/src/compile/shaders.ts +3 -3
  96. package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
  97. package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
  98. package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
  99. package/src/dev/androidDev.ts +1 -1
  100. package/src/dev/clientTemplates.ts +18 -14
  101. package/src/dev/devServer.ts +78 -41
  102. package/src/dev/iosDev.ts +180 -0
  103. package/src/dev/webRunner.ts +83 -15
  104. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
  105. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
  106. package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
  107. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  108. package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
  109. package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
  110. package/src/{peers.ts → hosts/peers.ts} +2 -2
  111. package/src/{releases.ts → hosts/releases.ts} +1 -1
  112. package/src/index.ts +35 -480
  113. package/src/platform/api.ts +302 -0
  114. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  115. package/src/{config.ts → platform/config.ts} +1 -1
  116. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  117. package/src/plugin/contract.ts +438 -0
  118. package/src/plugin/emitKotlin.ts +357 -0
  119. package/src/plugin/emitSdk.ts +259 -0
  120. package/src/plugin/emitSwift.ts +336 -0
  121. package/src/plugin/emitWeb.ts +383 -0
  122. package/src/plugin/generate.ts +157 -0
  123. package/src/plugin/lines.ts +28 -0
  124. package/src/plugin/lower.ts +231 -0
  125. package/src/plugin/manifest.ts +192 -0
  126. package/src/{projectEnv.ts → project/env.ts} +33 -2
  127. package/src/{ignore.ts → project/ignore.ts} +164 -163
  128. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  129. package/src/{manifest.ts → project/manifest.ts} +1 -1
  130. package/src/{project.ts → project/materialize.ts} +2 -2
  131. package/src/project/paths.ts +20 -0
  132. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  133. package/src/{types.ts → project/types.ts} +0 -0
  134. package/runtime/materials/lightmap-baked-lite.mat +0 -175
  135. package/runtime/materials/lightmap-baked.mat +0 -176
  136. package/runtime/scene-harness.json +0 -1
  137. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  138. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  139. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  140. package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
  141. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  142. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  143. package/runtime/web/assets/index-BMt7AnC5.js +0 -2
  144. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  145. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  146. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  147. package/runtime/web/embed.html +0 -32
  148. package/runtime/web/embed.js +0 -171
  149. package/src/commands/design.ts +0 -846
  150. package/src/commands/update.ts +0 -211
  151. package/src/util.ts +0 -146
  152. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  153. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  154. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
@@ -1,175 +1,185 @@
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 { loadPeer } from "../peerInstall"
7
- import { projectRootOf } from "./scene"
8
- import { CliError, c, endProgress, flagBool, flagStr, log, note, progress, toKey, warnErr, type Args } from "../util"
9
-
10
- /*
11
- * `lecodes thumbs [path…]` — the scene editor's asset thumbnails, generated from the COMMAND LINE.
12
- *
13
- * The editor renders previews in a hidden browser host (3D assets need an engine), but image
14
- * thumbnails need no engine at all — and a .ktx2 has no picture until something decodes it. This
15
- * command fills the same cache the editor reads (`.lecodes/preview/<asset>.<hash>.png`, owned by
16
- * lecodes-3d-editor's server/previewCache) with no browser in the loop, so opening the editor on a
17
- * texture-heavy project shows a full grid immediately instead of rendering 40 thumbnails on the
18
- * spot.
19
- *
20
- * Incremental by construction: the cache file name carries the first 10 hex of the SOURCE file's
21
- * sha256, so an asset whose preview is already there is skipped after one hash — re-running over a
22
- * big project is nearly free. `--force` ignores that.
23
- *
24
- * Decoders, in the order tried: sharp (through the optional `lecodes-assets` peer — handles webp),
25
- * else jimp (bundled with this CLI: png / jpeg / bmp / gif / tiff). KTX2 goes through
26
- * KTX-Software's `ktx extract` first (it is a GPU-compressed format; nothing in JS reads it), and
27
- * the extracted PNG then takes the same resize path.
28
- *
29
- * NOT covered: models, materials and prefabs — those are RENDERED, which needs an engine. The
30
- * editor still does them on demand (and `lecodes render --desktop` is the browserless renderer if
31
- * that ever becomes a batch job).
32
- */
33
-
34
- const DEFAULT_SIZE = 256
35
- const IMAGE_EXT = /\.(png|jpe?g|webp|bmp|gif|tiff?|ktx2)$/i
36
- const RENDERED_EXT = /\.(glb|gltf|material\.ts|scene\.ts)$/i
37
-
38
- /** KTX-Software's `ktx` CLI (v4.3+ has `extract`), resolved like lecodes-assets resolves toktx. */
39
- const findKtx = (): string | null => {
40
- const exe = process.platform === "win32" ? "ktx.exe" : "ktx"
41
- const candidates = [
42
- process.env.LECODES_KTX,
43
- process.env.KTX_CLI,
44
- process.env.KTX_TOKTX?.replace(/toktx(\.exe)?$/i, exe),
45
- process.platform === "win32" ? join(process.env.LOCALAPPDATA ?? "", "Programs", "KTX-Software", "bin", exe) : null,
46
- process.platform === "win32" ? join(process.env.ProgramFiles ?? "", "KTX-Software", "bin", exe) : null,
47
- "/usr/local/bin/ktx",
48
- "/opt/homebrew/bin/ktx",
49
- ].filter((p): p is string => !!p)
50
- for (const path of candidates) { if (existsSync(path)) return path }
51
- try {
52
- execFileSync(exe, [ "--version" ], { stdio: "ignore" })
53
- return exe
54
- } catch { return null }
55
- }
56
-
57
- type Resizer = (file: string, size: number) => Promise<Uint8Array>
58
-
59
- /** sharp through the `lecodes-assets` peer (it declares it optional), or null when neither is
60
- * installed — jimp then covers everything but webp. */
61
- const sharpResizer = (): Resizer | null => {
62
- try {
63
- const req = createRequire(import.meta.url)
64
- const sharp = createRequire(req.resolve("lecodes-assets/cli"))("sharp")
65
- return async (file, size) =>
66
- sharp(file).resize({ width: size, height: size, fit: "inside", withoutEnlargement: true }).png().toBuffer()
67
- } catch { return null }
68
- }
69
-
70
- const jimpResizer = (): Resizer => async (file, size) => {
71
- const { Jimp } = await import("jimp")
72
- const img = await Jimp.read(file)
73
- if (Math.max(img.width, img.height) > size) img.scaleToFit({ w: size, h: size })
74
- return await img.getBuffer("image/png")
75
- }
76
-
77
- /** KTX2 → a temporary PNG (level 0, transcoded to RGBA8 when the payload is Basis-compressed). */
78
- const ktxToPng = (ktx: string, file: string, out: string): void => {
79
- const run = (args: string[]) => execFileSync(ktx, args, { stdio: [ "ignore", "ignore", "pipe" ], encoding: "utf8" })
80
- try {
81
- run([ "extract", "--transcode", "rgba8", "--level", "0", file, out ])
82
- } catch (e: any) {
83
- // Only Basis (ETC1S/UASTC) payloads are transcodable; a plain uncompressed KTX2 extracts as-is
84
- run([ "extract", "--level", "0", file, out ])
85
- if (!existsSync(out)) throw e
86
- }
87
- }
88
-
89
- /** Every previewable file under a folder — the SAME sweep the editor's asset panel lists with
90
- * (node_modules + dotfiles out, nothing else): what this command doesn't cover, the browser would
91
- * end up rendering itself. */
92
- const collect = (dir: string): string[] => {
93
- const out: string[] = []
94
- for (const name of readdirSync(dir)) {
95
- if (name === "node_modules" || name.startsWith(".")) continue
96
- const full = join(dir, name)
97
- if (statSync(full).isDirectory()) out.push(...collect(full))
98
- else if (IMAGE_EXT.test(name)) out.push(full)
99
- }
100
- return out
101
- }
102
-
103
- export const thumbs = async (args: Args) => {
104
- const root = projectRootOf(process.cwd())
105
-
106
- // The scene editor owns the preview cache — installed into ~/.lecodes/peers on first use.
107
- const sceneServer = await loadPeer<typeof import("lecodes-3d-editor/server")>("lecodes-3d-editor", "server", { for: "lecodes thumbs" })
108
-
109
- const size = Number(flagStr(args, "size")) || DEFAULT_SIZE
110
- const force = flagBool(args, "force")
111
-
112
- // Targets: the paths given (a file or a folder each), else the whole project.
113
- let files: string[] = []
114
- const rendered: string[] = []
115
- if (args._.length > 0) {
116
- for (const arg of args._) {
117
- const abs = resolve(process.cwd(), arg)
118
- if (!existsSync(abs)) throw new CliError(`No such file or folder: ${arg}`)
119
- if (statSync(abs).isDirectory()) files.push(...collect(abs))
120
- else if (IMAGE_EXT.test(abs)) files.push(abs)
121
- else if (RENDERED_EXT.test(abs)) rendered.push(arg)
122
- else throw new CliError(`Not a previewable asset: ${arg}`)
123
- }
124
- } else {
125
- files = collect(root)
126
- }
127
- files = [ ...new Set(files) ]
128
-
129
- if (files.length === 0 && rendered.length === 0) {
130
- log("No images to thumbnail.")
131
- return
132
- }
133
-
134
- const ktx = files.some((f) => f.toLowerCase().endsWith(".ktx2")) ? findKtx() : null
135
- const sharp = sharpResizer()
136
- const resize = sharp ?? jimpResizer()
137
- const tmp = mkdtempSync(join(tmpdir(), "lecodes-thumbs-"))
138
-
139
- let made = 0, fresh = 0
140
- const skipped: string[] = []
141
- try {
142
- for (const [ i, file ] of files.entries()) {
143
- const asset = "./" + toKey(relative(root, file))
144
- progress(i, files.length, asset)
145
- const hash = sceneServer.hashAsset(root, asset)
146
- if (!hash) continue
147
- if (!force && sceneServer.previewIsCurrent(root, asset, hash)) { fresh++; continue }
148
-
149
- const ext = extname(file).toLowerCase()
150
- if (ext === ".webp" && !sharp) { skipped.push(`${asset} (webp needs sharp — npm install -g lecodes-assets)`); continue }
151
- try {
152
- let source = file
153
- if (ext === ".ktx2") {
154
- if (!ktx) { skipped.push(`${asset} (KTX2 needs KTX-Software's \`ktx\` — set LECODES_KTX or put it on PATH)`); continue }
155
- source = join(tmp, `${i}.png`)
156
- ktxToPng(ktx, file, source)
157
- }
158
- sceneServer.writePreviewFile(root, asset, await resize(source, size))
159
- made++
160
- } catch (e: any) {
161
- skipped.push(`${asset} (${(e?.message ?? String(e)).split("\n")[0]})`)
162
- }
163
- }
164
- } finally {
165
- endProgress()
166
- rmSync(tmp, { recursive: true, force: true })
167
- }
168
-
169
- log(`${c.bold(String(made))} generated · ${fresh} already current${skipped.length > 0 ? ` · ${skipped.length} skipped` : ""}`)
170
- for (const s of skipped) warnErr(s)
171
- if (rendered.length > 0) {
172
- warnErr(`Models, materials and prefabs are RENDERED, not decoded — the scene editor does those: ${rendered.join(", ")}`)
173
- }
174
- note(`Cache: ${join(root, ".lecodes", "preview")} (re-runs skip everything unchanged)`)
175
- }
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 { loadPeer } from "../hosts/peerInstall"
7
+ import { projectRootOf } from "./scene"
8
+ import { CliError, bool, c, defineCommand, endProgress, log, note, num, progress, warnErr } from "../cli"
9
+ import { toKey } from "../project/paths"
10
+
11
+ /*
12
+ * `lecodes thumbs [path…]` — the scene editor's asset thumbnails, generated from the COMMAND LINE.
13
+ *
14
+ * The editor renders previews in a hidden browser host (3D assets need an engine), but image
15
+ * thumbnails need no engine at all — and a .ktx2 has no picture until something decodes it. This
16
+ * command fills the same cache the editor reads (`.lecodes/preview/<asset>.<hash>.png`, owned by
17
+ * lecodes-3d-editor's server/previewCache) with no browser in the loop, so opening the editor on a
18
+ * texture-heavy project shows a full grid immediately instead of rendering 40 thumbnails on the
19
+ * spot.
20
+ *
21
+ * Incremental by construction: the cache file name carries the first 10 hex of the SOURCE file's
22
+ * sha256, so an asset whose preview is already there is skipped after one hash — re-running over a
23
+ * big project is nearly free. `--force` ignores that.
24
+ *
25
+ * Decoders, in the order tried: sharp (through the optional `lecodes-assets` peer — handles webp),
26
+ * else jimp (bundled with this CLI: png / jpeg / bmp / gif / tiff). KTX2 goes through
27
+ * KTX-Software's `ktx extract` first (it is a GPU-compressed format; nothing in JS reads it), and
28
+ * the extracted PNG then takes the same resize path.
29
+ *
30
+ * NOT covered: models, materials and prefabs — those are RENDERED, which needs an engine. The
31
+ * editor still does them on demand (and `lecodes render --desktop` is the browserless renderer if
32
+ * that ever becomes a batch job).
33
+ */
34
+
35
+ const DEFAULT_SIZE = 256
36
+ const IMAGE_EXT = /\.(png|jpe?g|webp|bmp|gif|tiff?|ktx2)$/i
37
+ const RENDERED_EXT = /\.(glb|gltf|material\.ts|scene\.ts)$/i
38
+
39
+ /** KTX-Software's `ktx` CLI (v4.3+ has `extract`), resolved like lecodes-assets resolves toktx. */
40
+ const findKtx = (): string | null => {
41
+ const exe = process.platform === "win32" ? "ktx.exe" : "ktx"
42
+ const candidates = [
43
+ process.env.LECODES_KTX,
44
+ process.env.KTX_CLI,
45
+ process.env.KTX_TOKTX?.replace(/toktx(\.exe)?$/i, exe),
46
+ process.platform === "win32" ? join(process.env.LOCALAPPDATA ?? "", "Programs", "KTX-Software", "bin", exe) : null,
47
+ process.platform === "win32" ? join(process.env.ProgramFiles ?? "", "KTX-Software", "bin", exe) : null,
48
+ "/usr/local/bin/ktx",
49
+ "/opt/homebrew/bin/ktx",
50
+ ].filter((p): p is string => !!p)
51
+ for (const path of candidates) { if (existsSync(path)) return path }
52
+ try {
53
+ execFileSync(exe, [ "--version" ], { stdio: "ignore" })
54
+ return exe
55
+ } catch { return null }
56
+ }
57
+
58
+ type Resizer = (file: string, size: number) => Promise<Uint8Array>
59
+
60
+ /** sharp through the `lecodes-assets` peer (it declares it optional), or null when neither is
61
+ * installed — jimp then covers everything but webp. */
62
+ const sharpResizer = (): Resizer | null => {
63
+ try {
64
+ const req = createRequire(import.meta.url)
65
+ const sharp = createRequire(req.resolve("lecodes-assets/cli"))("sharp")
66
+ return async (file, size) =>
67
+ sharp(file).resize({ width: size, height: size, fit: "inside", withoutEnlargement: true }).png().toBuffer()
68
+ } catch { return null }
69
+ }
70
+
71
+ const jimpResizer = (): Resizer => async (file, size) => {
72
+ const { Jimp } = await import("jimp")
73
+ const img = await Jimp.read(file)
74
+ if (Math.max(img.width, img.height) > size) img.scaleToFit({ w: size, h: size })
75
+ return await img.getBuffer("image/png")
76
+ }
77
+
78
+ /** KTX2 → a temporary PNG (level 0, transcoded to RGBA8 when the payload is Basis-compressed). */
79
+ const ktxToPng = (ktx: string, file: string, out: string): void => {
80
+ const run = (args: string[]) => execFileSync(ktx, args, { stdio: [ "ignore", "ignore", "pipe" ], encoding: "utf8" })
81
+ try {
82
+ run([ "extract", "--transcode", "rgba8", "--level", "0", file, out ])
83
+ } catch (e: any) {
84
+ // Only Basis (ETC1S/UASTC) payloads are transcodable; a plain uncompressed KTX2 extracts as-is
85
+ run([ "extract", "--level", "0", file, out ])
86
+ if (!existsSync(out)) throw e
87
+ }
88
+ }
89
+
90
+ /** Every previewable file under a folder — the SAME sweep the editor's asset panel lists with
91
+ * (node_modules + dotfiles out, nothing else): what this command doesn't cover, the browser would
92
+ * end up rendering itself. */
93
+ const collect = (dir: string): string[] => {
94
+ const out: string[] = []
95
+ for (const name of readdirSync(dir)) {
96
+ if (name === "node_modules" || name.startsWith(".")) continue
97
+ const full = join(dir, name)
98
+ if (statSync(full).isDirectory()) out.push(...collect(full))
99
+ else if (IMAGE_EXT.test(name)) out.push(full)
100
+ }
101
+ return out
102
+ }
103
+
104
+ export default defineCommand({
105
+ name: "thumbs",
106
+ summary: "Asset thumbnails for the scene editor, without a browser",
107
+ usage: "[path…]",
108
+ description: "Decodes the project's images (KTX2 too, via KTX-Software's ktx tool) into the editor's preview cache. Incremental: the cached name carries the asset's content hash, so unchanged files are skipped. Models, materials and prefabs are rendered by the editor, not here.",
109
+ flags: {
110
+ force: bool("redo even the up-to-date ones"),
111
+ size: num("longest edge, px", { default: DEFAULT_SIZE }),
112
+ },
113
+ run: ({ args, flags }) => thumbs(args, flags.size, flags.force),
114
+ })
115
+
116
+ const thumbs = async (targets: string[], size: number, force: boolean): Promise<void> => {
117
+ const root = projectRootOf(process.cwd())
118
+
119
+ // The scene editor owns the preview cache — installed into ~/.lecodes/peers on first use.
120
+ const sceneServer = await loadPeer<typeof import("lecodes-3d-editor/server")>("lecodes-3d-editor", "server", { for: "lecodes thumbs" })
121
+
122
+ // Targets: the paths given (a file or a folder each), else the whole project.
123
+ let files: string[] = []
124
+ const rendered: string[] = []
125
+ if (targets.length > 0) {
126
+ for (const arg of targets) {
127
+ const abs = resolve(process.cwd(), arg)
128
+ if (!existsSync(abs)) throw new CliError(`No such file or folder: ${arg}`)
129
+ if (statSync(abs).isDirectory()) files.push(...collect(abs))
130
+ else if (IMAGE_EXT.test(abs)) files.push(abs)
131
+ else if (RENDERED_EXT.test(abs)) rendered.push(arg)
132
+ else throw new CliError(`Not a previewable asset: ${arg}`)
133
+ }
134
+ } else {
135
+ files = collect(root)
136
+ }
137
+ files = [ ...new Set(files) ]
138
+
139
+ if (files.length === 0 && rendered.length === 0) {
140
+ log("No images to thumbnail.")
141
+ return
142
+ }
143
+
144
+ const ktx = files.some((f) => f.toLowerCase().endsWith(".ktx2")) ? findKtx() : null
145
+ const sharp = sharpResizer()
146
+ const resize = sharp ?? jimpResizer()
147
+ const tmp = mkdtempSync(join(tmpdir(), "lecodes-thumbs-"))
148
+
149
+ let made = 0, fresh = 0
150
+ const skipped: string[] = []
151
+ try {
152
+ for (const [ i, file ] of files.entries()) {
153
+ const asset = "./" + toKey(relative(root, file))
154
+ progress(i, files.length, asset)
155
+ const hash = sceneServer.hashAsset(root, asset)
156
+ if (!hash) continue
157
+ if (!force && sceneServer.previewIsCurrent(root, asset, hash)) { fresh++; continue }
158
+
159
+ const ext = extname(file).toLowerCase()
160
+ if (ext === ".webp" && !sharp) { skipped.push(`${asset} (webp needs sharp — npm install -g lecodes-assets)`); continue }
161
+ try {
162
+ let source = file
163
+ if (ext === ".ktx2") {
164
+ if (!ktx) { skipped.push(`${asset} (KTX2 needs KTX-Software's \`ktx\` — set LECODES_KTX or put it on PATH)`); continue }
165
+ source = join(tmp, `${i}.png`)
166
+ ktxToPng(ktx, file, source)
167
+ }
168
+ sceneServer.writePreviewFile(root, asset, await resize(source, size))
169
+ made++
170
+ } catch (e: any) {
171
+ skipped.push(`${asset} (${(e?.message ?? String(e)).split("\n")[0]})`)
172
+ }
173
+ }
174
+ } finally {
175
+ endProgress()
176
+ rmSync(tmp, { recursive: true, force: true })
177
+ }
178
+
179
+ log(`${c.bold(String(made))} generated · ${fresh} already current${skipped.length > 0 ? ` · ${skipped.length} skipped` : ""}`)
180
+ for (const s of skipped) warnErr(s)
181
+ if (rendered.length > 0) {
182
+ warnErr(`Models, materials and prefabs are RENDERED, not decoded — the scene editor does those: ${rendered.join(", ")}`)
183
+ }
184
+ note(`Cache: ${join(root, ".lecodes", "preview")} (re-runs skip everything unchanged)`)
185
+ }
@@ -0,0 +1,226 @@
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, bool, c, defineCommand, log, note, readFlags, type Command } from "../../cli"
7
+ import { OPTIONAL_PEERS, peerSource, peerVersion } from "../../hosts/peers"
8
+ import { installPeers } from "../../hosts/peerInstall"
9
+ import { updateDesktop } from "../../hosts/desktopRenderer"
10
+ import { updateMatc } from "../../hosts/matcTool"
11
+ import { refreshTypes } from "./types"
12
+ import { compileShaders } from "../shaders"
13
+ import app from "../app"
14
+
15
+ /*
16
+ * `lecodes update` — one command that moves EVERYTHING a project depends on to its newest published
17
+ * state, in the order the pieces depend on each other:
18
+ *
19
+ * 1. desktop the LeCodes Desktop host `desktop run` / `render --desktop` use (= `desktop update`)
20
+ * 2. matc Filament's material compiler for local shader compiles (= `shaders update`)
21
+ * 3. shaders recompile the project's .mat files with that matc (= `shaders compile`)
22
+ * 4. types the IDE types (.lecodes/types + tsconfig) from the server (types.ts)
23
+ * 5. shells ios/ + android/ pins → newest SDK release + plugin tags, re-sync (= `app update`)
24
+ * 6. cli the npm installs — every installed optional peer (lecodes-headless, lecodes-design,
25
+ * lecodes-3d-editor, lecodes-assets, @napi-rs/canvas) and the CLI itself that is behind:
26
+ * peers in ~/.lecodes/peers (first-use installs, peerInstall.ts) are re-installed there
27
+ * — the renderer at THIS CLI's version, the rest at npm's latest in range; global ones
28
+ * (`npm install -g`, the old way) via `npm install -g …@latest`; the CLI itself last
29
+ * (the standalone binary can't come from npm — it says so and does the peers only)
30
+ *
31
+ * Every step is the existing subcommand, so nothing here can drift from what the individual
32
+ * commands do; this only sequences them and keeps going when one fails (the summary says which).
33
+ * Steps that the environment pins are SKIPPED, not overridden: LECODES_DESKTOP_EXE means a local
34
+ * host build (engine work — the cache is irrelevant), LECODES_MATC a local matc; the project steps
35
+ * need a project (a dir with app.json / .lecodes above the cwd) and the shell step a shell folder.
36
+ *
37
+ * `lecodes types` used to be its own command; the types step here replaced it (2026-09-04).
38
+ */
39
+
40
+ /** The nearest project at or above `start`: a dir with app.json, or a .lecodes/ that holds a project's
41
+ * manifest or types. NOT the bare `.lecodes/` marker projectEnv uses — the CLI's own config lives in
42
+ * ~/.lecodes, so that rule would turn any folder under the home dir into a "project" and the types
43
+ * step would scaffold .lecodes/types + tsconfig.json into it. */
44
+ const findProjectDir = (start: string): string | null => {
45
+ const home = resolve(homedir())
46
+ let dir = resolve(start)
47
+ while (true) {
48
+ if (dir !== home && (existsSync(join(dir, "app.json")) || existsSync(join(dir, ".lecodes", "manifest.json")) || existsSync(join(dir, ".lecodes", "types")))) return dir
49
+ const parent = dirname(dir)
50
+ if (parent === dir) return null
51
+ dir = parent
52
+ }
53
+ }
54
+
55
+ /** Run another command's subcommand with its default flags — the step IS that command, so it can't drift. */
56
+ const runSubcommand = async (parent: Command<any>, name: string): Promise<void> => {
57
+ const sub = parent.commands?.find((x) => x.name === name)
58
+ if (!sub?.run) throw new CliError(`lecodes ${parent.name} ${name} is not a runnable command`)
59
+ const path = [parent.name, name]
60
+ await sub.run({ args: [], flags: readFlags(sub.flags ?? {}, {}, path), raw: {}, argv: [], path })
61
+ }
62
+
63
+ type Outcome = "updated" | "ok" | "skipped" | "failed"
64
+ type Step = { name: string, outcome: Outcome, detail?: string }
65
+
66
+ const NPM = "https://registry.npmjs.org"
67
+
68
+ /** `latest` dist-tag of an npm package, or null when npm is unreachable / the package is unknown. */
69
+ const npmLatest = async (name: string): Promise<string | null> => {
70
+ try {
71
+ const res = await fetch(`${NPM}/${name}/latest`, { signal: AbortSignal.timeout(4000) })
72
+ if (!res.ok) return null
73
+ const json = await res.json() as { version?: string }
74
+ return json.version ?? null
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ const semverNewer = (a: string, b: string): number => {
81
+ const pa = a.split(/[.-]/).map(Number), pb = b.split(/[.-]/).map(Number)
82
+ for (let i = 0; i < 3; i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d }
83
+ return 0
84
+ }
85
+
86
+ /** Running straight from the repo checkout (src/commands/update/index.ts), not a built dist/binary. */
87
+ const FROM_SOURCE = /[\\/]src[\\/]commands[\\/]/.test(import.meta.url)
88
+ /** The standalone release build: the bundle + chisel sit next to the executable (same test as index.ts). */
89
+ const STANDALONE = existsSync(join(dirname(process.execPath), "runtime")) &&
90
+ existsSync(join(dirname(process.execPath), process.platform === "win32" ? "chisel.exe" : "chisel"))
91
+
92
+ export default defineCommand({
93
+ name: "update",
94
+ summary: "Move everything the project depends on to its newest published state",
95
+ description: "In dependency order: the desktop host (`desktop update`), matc (`shaders update`), the project's shader compiles, the IDE types (.lecodes/types/ + tsconfig.json, from the server — the former `lecodes types`), the ios/android shell pins (`app update`), then the GLOBAL npm installs: every installed optional peer (lecodes-headless, lecodes-design, lecodes-3d-editor, lecodes-assets) and this CLI that is behind npm's latest gets `npm install -g`.\n\nPinned tools are skipped, not overridden: LECODES_DESKTOP_EXE / LECODES_MATC mean a local build. Keeps going when a step fails; the summary says which.",
96
+ flags: {
97
+ local: bool("types from the SDK bundled in this CLI"),
98
+ "no-shells": bool("leave ios/android pins alone (no re-sync)"),
99
+ check: bool("npm step: report what's behind, install nothing"),
100
+ "no-cli": bool("skip the npm step (offline)"),
101
+ },
102
+ examples: ["lecodes update", "lecodes update --check", "lecodes update --local --no-shells"],
103
+ run: async ({ flags }) => {
104
+ const steps: Step[] = []
105
+ const heading = (name: string, what: string) => log(`\n${c.bold(name)} ${c.dim(what)}`)
106
+ const run = async (name: string, what: string, fn: () => Promise<Outcome | [Outcome, string]>) => {
107
+ heading(name, what)
108
+ try {
109
+ const r = await fn()
110
+ const [outcome, detail] = Array.isArray(r) ? r : [r, undefined]
111
+ steps.push({ name, outcome, detail })
112
+ if (outcome === "skipped" && detail) note(`skipped — ${detail}`)
113
+ } catch (e) {
114
+ const msg = e instanceof CliError ? e.message : e instanceof Error ? e.message : String(e)
115
+ log(c.red("✗ ") + msg)
116
+ steps.push({ name, outcome: "failed", detail: msg })
117
+ }
118
+ }
119
+
120
+ const root = findProjectDir(process.cwd())
121
+
122
+ // 1. desktop host
123
+ await run("desktop", "LeCodes Desktop host (desktop run / render --desktop)", async () => {
124
+ 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)`]
125
+ await updateDesktop()
126
+ return "ok"
127
+ })
128
+
129
+ // 2. matc
130
+ await run("matc", "Filament material compiler for local shader compiles", async () => {
131
+ if (process.env.LECODES_MATC) return ["skipped", `LECODES_MATC=${process.env.LECODES_MATC} (a local matc)`]
132
+ await updateMatc()
133
+ return "ok"
134
+ })
135
+
136
+ // 3. shaders (project)
137
+ await run("shaders", "recompile the project's .mat shaders with the current matc", async () => {
138
+ if (!root) return ["skipped", "not inside a project"]
139
+ await compileShaders()
140
+ return "ok"
141
+ })
142
+
143
+ // 4. types (project)
144
+ await run("types", flags.local ? "IDE types from the SDK bundled in this CLI" : "IDE types from the server", async () => {
145
+ if (!root) return ["skipped", "not inside a project"]
146
+ await refreshTypes({ local: flags.local, check: false })
147
+ return "ok"
148
+ })
149
+
150
+ // 5. native shells (project)
151
+ await run("shells", "ios/ + android/ pins → newest SDK release + plugin tags, then re-sync", async () => {
152
+ if (!root) return ["skipped", "not inside a project"]
153
+ if (flags["no-shells"]) return ["skipped", "--no-shells"]
154
+ const present = ["ios", "android"].filter((d) => existsSync(join(root, d)) && readdirSync(join(root, d)).length > 0)
155
+ if (present.length === 0) return ["skipped", "no ios/ or android/ shell in this project (`lecodes app init ios|android`)"]
156
+ await runSubcommand(app, "update")
157
+ return "ok"
158
+ })
159
+
160
+ // 6. this CLI + its optional peers — the GLOBAL npm installs (`npm install -g`, how the CLI and its
161
+ // peers are installed): every installed one that is behind npm's latest moves forward. The peers go
162
+ // first, the CLI itself last — replacing the running CLI's files is fine (this process already
163
+ // holds its code), but its new version is what the next invocation runs, so nothing after it here.
164
+ // Peers that are NOT installed stay that way: they're optional, installing one is a choice.
165
+ await run("cli", "lecodes-cli + its optional peers (global npm installs)", async () => {
166
+ if (flags["no-cli"]) return ["skipped", "--no-cli"]
167
+ if (FROM_SOURCE) return ["skipped", "running from the repo checkout — the CLI and its peers are the repo's own"]
168
+ // Where each peer lives decides how it moves: the CLI's own prefix (~/.lecodes/peers, filled on
169
+ // first use) is re-installed by peerInstall — the renderer there tracks THIS CLI's version, not
170
+ // npm's latest, because compiler and headless host are one ABI set; a global install
171
+ // (`npm install -g`, the old way) moves through npm like before.
172
+ const rows: { name: string, installed: string | null, latest: string | null, where: "global" | "prefix" | null }[] = []
173
+ for (const [name, probe] of OPTIONAL_PEERS) {
174
+ const where = peerSource(name, probe)
175
+ const installed = peerVersion(name, probe)
176
+ const latest = !installed ? null
177
+ : where === "prefix" && name === "lecodes-headless" ? pkg.version
178
+ : await npmLatest(name)
179
+ rows.push({ name, installed, latest, where })
180
+ }
181
+ rows.push({ name: "lecodes-cli", installed: pkg.version, latest: await npmLatest("lecodes-cli"), where: "global" })
182
+ if (rows.every((r) => r.latest === null)) return ["skipped", "npm unreachable"]
183
+ const behind = rows.filter((r) => r.installed && r.latest && semverNewer(r.latest, r.installed) > 0)
184
+ for (const r of rows) {
185
+ const state = !r.installed ? c.dim("not installed (optional — installed on first use)")
186
+ : !r.latest ? c.dim("(npm: unknown)")
187
+ : behind.includes(r) ? c.yellow("→ " + r.latest)
188
+ : c.dim("(up to date)")
189
+ log(" " + r.name.padEnd(18) + " " + (r.installed ?? "").padEnd(8) + " " + (r.where === "prefix" ? c.dim("peers ") : "") + state)
190
+ }
191
+ if (behind.length === 0) return "ok"
192
+ const prefixBehind = behind.filter((r) => r.where === "prefix")
193
+ const globalSpecs = behind.filter((r) => r.where !== "prefix").map((r) => r.name + "@latest")
194
+ if (flags.check) {
195
+ return ["updated", behind.length + " behind — run: lecodes update" + (globalSpecs.length ? " (global: npm install -g " + globalSpecs.join(" ") + ")" : "")]
196
+ }
197
+ const done: string[] = []
198
+ if (prefixBehind.length) {
199
+ await installPeers(prefixBehind.map((r) => r.name), "lecodes update", { yes: true })
200
+ done.push(...prefixBehind.map((r) => r.name))
201
+ }
202
+ if (STANDALONE && globalSpecs.some((s) => s.startsWith("lecodes-cli@"))) {
203
+ note("this is the standalone build — the CLI updates by re-installing the release archive, not through npm; peers still go through npm")
204
+ }
205
+ const toInstall = STANDALONE ? globalSpecs.filter((s) => !s.startsWith("lecodes-cli@")) : globalSpecs
206
+ if (toInstall.length) {
207
+ log(" " + c.dim("$") + " npm install -g " + toInstall.join(" "))
208
+ const r = spawnSync("npm", ["install", "-g", ...toInstall], { stdio: "inherit", shell: true })
209
+ if (r.status !== 0) throw new CliError("npm install -g exited with " + (r.status ?? "a signal"))
210
+ done.push(...toInstall.map((s) => s.replace("@latest", "")))
211
+ }
212
+ if (done.length === 0) return ["skipped", "only the standalone CLI itself is behind — download the release"]
213
+ const cliToo = done.includes("lecodes-cli")
214
+ return ["updated", done.join(", ") + " → latest" + (cliToo ? " (the new CLI runs from your next command)" : "")]
215
+ })
216
+
217
+ // Summary — one line per step so a scrolled-away failure can't hide.
218
+ log(`\n${c.bold("Summary")}`)
219
+ const width = Math.max(...steps.map((s) => s.name.length))
220
+ for (const s of steps) {
221
+ const mark = s.outcome === "failed" ? c.red("✗") : s.outcome === "skipped" ? c.dim("–") : c.green("✓")
222
+ 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}`)
223
+ }
224
+ if (steps.some((s) => s.outcome === "failed")) process.exitCode = 1
225
+ },
226
+ })