lecodes-cli 0.19.2 → 0.20.1

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 (59) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +1324 -658
  3. package/package.json +9 -9
  4. package/runtime/materials/decal-relief.mat +178 -0
  5. package/runtime/materials/decal.mat +172 -0
  6. package/runtime/materials/lightmap-baked-lite.mat +175 -0
  7. package/runtime/materials/lightmap-baked.mat +176 -0
  8. package/runtime/materials/lightmap.mat +181 -0
  9. package/runtime/materials/lit.mat +43 -0
  10. package/runtime/materials/particles-quad.mat +171 -0
  11. package/runtime/materials/particles.mat +121 -0
  12. package/runtime/materials/shadow.mat +21 -0
  13. package/runtime/materials/terrain.mat +134 -0
  14. package/runtime/materials/unlit-transparent.mat +42 -0
  15. package/runtime/materials/unlit.mat +44 -0
  16. package/runtime/materials/video.mat +31 -0
  17. package/runtime/sdk-types.json +1 -1
  18. package/runtime/web/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
  19. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  20. package/runtime/web/assets/basis-C64VHDVD.js +1 -0
  21. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  22. package/runtime/web/assets/createViewerLite-C_bkKXLg.js +852 -0
  23. package/runtime/web/assets/draco-BiISTFcR.js +118 -0
  24. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  25. package/runtime/web/assets/index-BTB5KV9x.js +2 -0
  26. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +810 -0
  27. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +8 -0
  28. package/runtime/web/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  29. package/runtime/web/assets/worker-Caf-yYEI.js +2 -0
  30. package/runtime/web/embed.html +32 -0
  31. package/runtime/web/embed.js +171 -0
  32. package/src/cmgenTool.ts +17 -9
  33. package/src/commands/app.ts +6 -1
  34. package/src/commands/appDesktop.ts +27 -4
  35. package/src/commands/appShared.ts +145 -3
  36. package/src/commands/appTemplates.ts +28 -4
  37. package/src/commands/assets.ts +3 -7
  38. package/src/commands/design.ts +9 -26
  39. package/src/commands/desktop.ts +9 -1
  40. package/src/commands/dev.ts +64 -0
  41. package/src/commands/projectTemplates.ts +17 -4
  42. package/src/commands/render.ts +9 -11
  43. package/src/commands/scene.ts +2 -6
  44. package/src/commands/shaders.ts +17 -1
  45. package/src/commands/shadersNew.ts +133 -0
  46. package/src/commands/test.ts +2 -1
  47. package/src/commands/thumbs.ts +3 -6
  48. package/src/commands/update.ts +42 -21
  49. package/src/desktopRenderer.ts +32 -2
  50. package/src/desktopScript.ts +1 -1
  51. package/src/dev/androidDev.ts +154 -0
  52. package/src/dev/devServer.ts +53 -6
  53. package/src/dev/webRunner.ts +22 -0
  54. package/src/index.ts +24 -4
  55. package/src/matcTool.ts +31 -19
  56. package/src/peerInstall.ts +160 -0
  57. package/src/peers.ts +48 -14
  58. package/src/projectEnv.ts +4 -0
  59. package/src/releases.ts +16 -0
@@ -0,0 +1,154 @@
1
+ import { existsSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { spawnSync } from "node:child_process"
4
+ import { CliError, note, warnErr } from "../util"
5
+
6
+ /*
7
+ * `lecodes dev --android` — open the dev URL in the LeCodes app on the connected phone, through adb.
8
+ *
9
+ * Why adb instead of "scan the QR": the phone reaches this machine over `adb reverse` (its
10
+ * 127.0.0.1:<port> is our port), so it works over USB with no Wi-Fi, no LAN-address guessing
11
+ * (WSL/Docker/VPN adapters), no firewall prompt. The launch is an EXPLICIT component intent — the
12
+ * app's VIEW filter is pinned to le.codes/qr, and an explicit start bypasses filter matching — with
13
+ * the plain dev.js url: the app's initial.js only fetches a url that ends in ".js", anything else is
14
+ * sent to le.codes/api/code, which answers 400 for a dev bundle (the "Произошла ошибка при открытии
15
+ * проекта" screen). That is also why the poll transport is a PATH (/dev-poll.js), never `?t=poll`.
16
+ *
17
+ * A device run has no frame cap (unlike the desktop host), so every exit path force-stops the app:
18
+ * a 3D scene left rendering is a sustained GPU load on a charging phone.
19
+ */
20
+
21
+ export const DEFAULT_ANDROID_PACKAGE = "com.letary.lecodes"
22
+
23
+ export type AndroidDevOptions = {
24
+ port: number
25
+ /** The device-side url to open (with `adb reverse` in place, 127.0.0.1:<port> is this machine). */
26
+ url: string
27
+ serial?: string
28
+ apk?: string
29
+ packageName?: string
30
+ }
31
+
32
+ export type AndroidDevSession = {
33
+ serial: string
34
+ packageName: string
35
+ adb: string
36
+ /** `adb exec-out screencap -p` → the file. */
37
+ screenshot: (file: string) => boolean
38
+ /** force-stop the app + drop the reverse forward. Idempotent. */
39
+ stop: () => void
40
+ }
41
+
42
+ const adbCandidates = (): string[] => {
43
+ const out: string[] = []
44
+ if (process.env.LECODES_ADB) out.push(process.env.LECODES_ADB)
45
+ out.push("adb")
46
+ const exe = process.platform === "win32" ? "adb.exe" : "adb"
47
+ for (const home of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT]) {
48
+ if (home) out.push(join(home, "platform-tools", exe))
49
+ }
50
+ if (process.platform === "win32" && process.env.LOCALAPPDATA) out.push(join(process.env.LOCALAPPDATA, "Android", "Sdk", "platform-tools", exe))
51
+ if (process.env.HOME) out.push(join(process.env.HOME, "Android", "Sdk", "platform-tools", exe), join(process.env.HOME, "Library", "Android", "sdk", "platform-tools", exe))
52
+ return out
53
+ }
54
+
55
+ const run = (adb: string, args: string[], input?: string): { ok: boolean, out: string } => {
56
+ const r = spawnSync(adb, args, { encoding: "utf8", input, windowsHide: true, maxBuffer: 64 * 1024 * 1024 })
57
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim()
58
+ return { ok: r.status === 0, out }
59
+ }
60
+
61
+ /** The adb binary: LECODES_ADB, PATH, then the usual SDK locations. */
62
+ export const resolveAdb = (): string => {
63
+ for (const cand of adbCandidates()) {
64
+ if (cand !== "adb" && !existsSync(cand)) continue
65
+ const r = spawnSync(cand, ["version"], { encoding: "utf8", windowsHide: true })
66
+ if (r.status === 0) return cand
67
+ }
68
+ throw new CliError("adb not found — install Android platform-tools (or point LECODES_ADB at adb).")
69
+ }
70
+
71
+ /** Connected devices in the `device` state (unauthorized/offline ones are reported, not used). */
72
+ export const listDevices = (adb: string): { serial: string, state: string, model?: string }[] => {
73
+ const { out } = run(adb, ["devices", "-l"])
74
+ const devices: { serial: string, state: string, model?: string }[] = []
75
+ for (const line of out.split(/\r?\n/).slice(1)) {
76
+ const m = /^(\S+)\s+(\S+)(.*)$/.exec(line.trim())
77
+ if (!m) continue
78
+ const model = /model:(\S+)/.exec(m[3] ?? "")?.[1]
79
+ devices.push({ serial: m[1], state: m[2], model })
80
+ }
81
+ return devices
82
+ }
83
+
84
+ const pickDevice = (adb: string, serial: string | undefined): string => {
85
+ const all = listDevices(adb)
86
+ if (serial) {
87
+ const d = all.find((x) => x.serial === serial)
88
+ if (!d) throw new CliError(`No device "${serial}" — adb sees: ${all.map((x) => `${x.serial} (${x.state})`).join(", ") || "nothing"}.`)
89
+ if (d.state !== "device") throw new CliError(`Device ${serial} is "${d.state}" — accept the USB-debugging prompt on the phone.`)
90
+ return serial
91
+ }
92
+ const ready = all.filter((d) => d.state === "device")
93
+ if (ready.length === 1) return ready[0].serial
94
+ if (ready.length === 0) {
95
+ const other = all.map((d) => `${d.serial} (${d.state})`).join(", ")
96
+ throw new CliError(other
97
+ ? `No usable device: adb sees ${other}. "unauthorized" = accept the USB-debugging prompt on the phone; "offline" = replug.`
98
+ : "No Android device: enable USB debugging (Developer options), plug the phone in, accept the prompt — or `adb connect <ip>:5555` for Wi-Fi debugging.")
99
+ }
100
+ throw new CliError(`Several devices: ${ready.map((d) => `${d.serial}${d.model ? ` (${d.model})` : ""}`).join(", ")} — pick one with --serial <id>.`)
101
+ }
102
+
103
+ /** Install (optional), forward the port, launch the app on the dev url. Returns the session handle. */
104
+ export const launchOnAndroid = (opts: AndroidDevOptions): AndroidDevSession => {
105
+ const adb = resolveAdb()
106
+ const serial = pickDevice(adb, opts.serial)
107
+ const packageName = opts.packageName ?? process.env.LECODES_ANDROID_PACKAGE ?? DEFAULT_ANDROID_PACKAGE
108
+ const dev = (args: string[]) => run(adb, ["-s", serial, ...args])
109
+
110
+ if (opts.apk) {
111
+ if (!existsSync(opts.apk)) throw new CliError(`--apk: ${opts.apk} does not exist.`)
112
+ note(`Installing ${opts.apk}…`)
113
+ const r = dev(["install", "-r", opts.apk])
114
+ if (!r.ok || !/Success/.test(r.out)) {
115
+ throw new CliError(`adb install failed: ${r.out}${/USER_RESTRICTED/.test(r.out) ? "\n(MIUI: enable \"Install via USB\" in Developer options.)" : ""}`)
116
+ }
117
+ }
118
+ const installed = dev(["shell", "pm", "path", packageName])
119
+ if (!installed.ok || !installed.out.includes("package:")) {
120
+ throw new CliError(`${packageName} is not installed on ${serial} — pass --apk <file> (lecodes-android: .\\gradlew :app:assembleDebug → app/build/outputs/apk/debug/app-debug.apk).`)
121
+ }
122
+
123
+ // The phone's 127.0.0.1:<port> → this machine's dev server. Survives Wi-Fi changes and VPNs.
124
+ const rev = dev(["reverse", `tcp:${opts.port}`, `tcp:${opts.port}`])
125
+ if (!rev.ok) warnErr(`adb reverse failed (${rev.out}) — the phone must reach the LAN url instead.`)
126
+
127
+ dev(["shell", "am", "force-stop", packageName])
128
+ dev(["logcat", "-c"])
129
+ const start = dev(["shell", "am", "start", "-n", `${packageName}/.MainActivity`, "-a", "android.intent.action.VIEW", "-d", opts.url])
130
+ if (!start.ok || /Error/.test(start.out)) throw new CliError(`Could not launch ${packageName}: ${start.out}`)
131
+
132
+ let stopped = false
133
+ return {
134
+ serial, packageName, adb,
135
+ screenshot: (file: string) => {
136
+ const r = spawnSync(adb, ["-s", serial, "exec-out", "screencap", "-p"], { windowsHide: true, maxBuffer: 64 * 1024 * 1024 })
137
+ if (r.status !== 0 || !r.stdout || r.stdout.length === 0) return false
138
+ writeFileSync(file, r.stdout)
139
+ return true
140
+ },
141
+ stop: () => {
142
+ if (stopped) return
143
+ stopped = true
144
+ dev(["shell", "am", "force-stop", packageName])
145
+ dev(["reverse", "--remove", `tcp:${opts.port}`])
146
+ },
147
+ }
148
+ }
149
+
150
+ /** Crash lines for the app since the launch (logcat was cleared then): the native + Java fatals. */
151
+ export const androidCrashLines = (session: AndroidDevSession): string[] => {
152
+ const { out } = run(session.adb, ["-s", session.serial, "logcat", "-d", "-v", "brief"])
153
+ return out.split(/\r?\n/).filter((l) => /FATAL EXCEPTION|Fatal signal|\[crash\]/.test(l) || (/AndroidRuntime/.test(l) && l.includes(session.packageName))).slice(-20)
154
+ }
@@ -24,6 +24,9 @@ import { makeStackDemangler } from "../compile/nativeStack"
24
24
  * GET /t/<tok>/version {"v":N} — polling fallback
25
25
  * POST /t/<tok>/log log/error backchannel for polling clients
26
26
  * WS /t/<tok>/ws reload push + log/error backchannel
27
+ * GET /t/<tok>/web/[file] the Web Lite browser runner (viewer-lite dist-embed, `dev --web`):
28
+ * it loads bundle.js?t=web — the bundle WITHOUT the native prelude;
29
+ * the page owns reload (ws / version polling) and the log backchannel
27
30
  *
28
31
  * Compile is eager: the command calls recompile() on every file change; a successful compile bumps
29
32
  * `v` and broadcasts {type:"reload", v} to connected sockets (polling clients see /version move).
@@ -41,6 +44,8 @@ export type DevServerOptions = {
41
44
  port: number
42
45
  token: string
43
46
  entryOverride?: string
47
+ /** The Web Lite runner folder to serve under /web/ (src/dev/webRunner.ts); absent = no browser route. */
48
+ webDir?: string | null
44
49
  /** Diagnostics (compile errors, device logs); defaults to stderr-style silence-safe noop. */
45
50
  log?: (msg: string) => void
46
51
  }
@@ -51,6 +56,8 @@ export type RecompileResult =
51
56
 
52
57
  export type DevServer = {
53
58
  url: string
59
+ /** The browser runner page (`dev --web`), or null when no runner is vendored. */
60
+ webUrl: string | null
54
61
  /** The token base path ("/t/<token>"). */
55
62
  base: string
56
63
  /** The actually-bound port (differs from options.port when 0 was passed, e.g. in tests). */
@@ -68,7 +75,8 @@ const MIME: Record<string, string> = {
68
75
  mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4",
69
76
  mp4: "video/mp4", webm: "video/webm",
70
77
  ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2",
71
- json: "application/json", txt: "text/plain", js: "text/javascript",
78
+ json: "application/json", txt: "text/plain", js: "text/javascript", mjs: "text/javascript",
79
+ css: "text/css", html: "text/html", wasm: "application/wasm", map: "application/json",
72
80
  }
73
81
 
74
82
  const contentType = (path: string): string => {
@@ -182,22 +190,60 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
182
190
  }
183
191
 
184
192
  // ---- http --------------------------------------------------------------------------------------
185
- const transportOf = (url: URL): "ws" | "poll" => (url.searchParams.get("t") === "poll" ? "poll" : "ws")
193
+ // "web" = the browser runner: no prelude at all (the page brings its own reload + log channel).
194
+ const transportOf = (url: URL): "ws" | "poll" | "web" => {
195
+ const t = url.searchParams.get("t")
196
+ return t === "poll" ? "poll" : t === "web" ? "web" : "ws"
197
+ }
198
+ const withPrelude = (js: string, url: URL, v: number, forced?: "poll") => {
199
+ const transport = forced ?? transportOf(url)
200
+ return transport === "web" ? js : js + "\n" + renderPrelude({ origin, base, transport, v })
201
+ }
202
+ const webDir = options.webDir ?? null
186
203
 
187
204
  const handle = async (req: IncomingMessage, res: ServerResponse) => {
188
205
  const url = new URL(req.url ?? "/", origin)
189
206
  const path = url.pathname
207
+ // Browsers ask for it on every page load (the /web/ runner) — keep the console clean.
208
+ if (path === "/favicon.ico") { res.writeHead(204); res.end(); return }
190
209
  if (!path.startsWith(base + "/")) return sendText(res, 404, "text/plain", "Not found")
191
210
  const route = path.slice(base.length)
192
211
 
193
212
  if (route === "/dev.js") {
194
- return sendText(res, 200, "text/javascript",
195
- devJs + "\n" + renderPrelude({ origin, base, transport: transportOf(url), v: -1 }))
213
+ return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1))
214
+ }
215
+ // The poll transport as a PATH: the Android app's initial.js only fetches a launch url that ends
216
+ // in ".js" (anything else goes to le.codes/api/code), so `?t=poll` can't reach it. Same bundle,
217
+ // prelude polls instead of the websocket — what `dev --android` opens under Bun (no ws there).
218
+ if (route === "/dev-poll.js") {
219
+ return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1, "poll"))
196
220
  }
197
221
  if (route === "/bundle.js") {
198
222
  if (bundleJs === null) return sendText(res, 503, "text/plain", "No successful compile yet — check the terminal.")
199
- return sendText(res, 200, "text/javascript",
200
- bundleJs + "\n" + renderPrelude({ origin, base, transport: transportOf(url), v }))
223
+ res.setHeader("x-lecodes-v", String(v)) // the browser runner learns the version it runs
224
+ return sendText(res, 200, "text/javascript", withPrelude(bundleJs, url, v))
225
+ }
226
+ // The browser runner: a static folder with relative asset paths, so it needs the trailing slash.
227
+ if (route === "/web") {
228
+ res.writeHead(302, { location: `${base}/web/${url.search}` })
229
+ res.end()
230
+ return
231
+ }
232
+ if (route.startsWith("/web/")) {
233
+ if (!webDir) return sendText(res, 404, "text/plain", "No Web Lite runner vendored with this CLI (lecodes-viewer-lite dist-embed).")
234
+ const rel = route === "/web/" ? "embed.html" : route.slice("/web/".length)
235
+ if (rel.split("/").some((seg) => seg === ".." || seg === "" || seg === ".")) return sendText(res, 400, "text/plain", "Bad path")
236
+ const abs = join(webDir, ...rel.split("/"))
237
+ try {
238
+ if (!statSync(abs).isFile()) throw new Error("not a file")
239
+ } catch {
240
+ return sendText(res, 404, "text/plain", "Not found")
241
+ }
242
+ // The entry files (html, embed.js) must revalidate — a CLI update swaps them under the same URL.
243
+ const hashed = rel.startsWith("assets/")
244
+ res.writeHead(200, { "content-type": contentType(rel), "cache-control": hashed ? "max-age=31536000, immutable" : "no-store" })
245
+ createReadStream(abs).pipe(res)
246
+ return
201
247
  }
202
248
  if (route === "/version") {
203
249
  return sendJson(res, 200, { v })
@@ -279,6 +325,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
279
325
 
280
326
  return {
281
327
  url: `${origin}${base}/dev.js`,
328
+ webUrl: webDir ? `${origin}${base}/web/` : null,
282
329
  base,
283
330
  port: boundPort,
284
331
  v: () => v,
@@ -0,0 +1,22 @@
1
+ import { existsSync } from "node:fs"
2
+ import { createRequire } from "node:module"
3
+ import { dirname, join } from "node:path"
4
+ import { distRoot } from "../distRoot"
5
+
6
+ /**
7
+ * Where the browser runner for `lecodes dev --web` lives: viewer-lite's built `dist-embed`
8
+ * (embed.html + embed.js + assets/ — the Web Lite host as a static folder). Two shapes:
9
+ * - vendored next to the CLI as runtime/web (scripts/vendor-runtime.ts — the npm package and
10
+ * the standalone binary),
11
+ * - the lecodes-viewer-lite package's own dist-embed (monorepo dev after `bun run build:embed`).
12
+ * `null` = no runner available (the dev server then serves everything but /web/).
13
+ */
14
+ export const resolveWebRunnerDir = (): string | null => {
15
+ const candidates = [join(distRoot, "runtime", "web")]
16
+ try {
17
+ const pkg = createRequire(import.meta.url).resolve("lecodes-viewer-lite/package.json")
18
+ candidates.push(join(dirname(pkg), "dist-embed"))
19
+ } catch { /* no package: standalone */ }
20
+ for (const dir of candidates) if (existsSync(join(dir, "embed.html")) && existsSync(join(dir, "embed.js"))) return dir
21
+ return null
22
+ }
package/src/index.ts CHANGED
@@ -31,7 +31,7 @@ import { CliError, c, log, parseArgs, type Args } from "./util"
31
31
  import { installedDesktopVersions, selectedDesktopVersion } from "./desktopRenderer"
32
32
  import { installedMatcVersions } from "./matcTool"
33
33
  import { loadProjectEnv } from "./projectEnv"
34
- import { OPTIONAL_PEERS, peerVersion } from "./peers"
34
+ import { OPTIONAL_PEERS, peerSource, peerVersion } from "./peers"
35
35
 
36
36
  /* `install` is a standalone-binary-only command: it installs the shipped bundle onto PATH, so it's only
37
37
  * meaningful when that bundle actually sits next to the executable — the `chisel` bundler + `runtime/`
@@ -120,6 +120,17 @@ ${c.bold("Commands:")}
120
120
  --host <ip> LAN address to advertise (default: auto-detect)
121
121
  --entry <path> override the entrypoint
122
122
  --no-qr don't render the QR code
123
+ --android open the dev url in the LeCodes app on the phone
124
+ plugged in over adb (adb reverse: no Wi-Fi/LAN
125
+ address needed). --serial <id> picks a device,
126
+ --apk <file> installs first, --package <name>
127
+ (default com.letary.lecodes). Device logs stream
128
+ here. --time <s> --png <file>: bounded check —
129
+ screenshot after <s> seconds, force-stop, exit
130
+ (exit 1 on a crash). Ctrl+C force-stops the app.
131
+ --web also open the app in the browser (Web Lite host:
132
+ same bundle + hot reload, camera/map plugins
133
+ have web twins; the /web/ URL is served always)
123
134
  --desktop also open the app in the native desktop host
124
135
  (same hot reload; window from app.json's
125
136
  desktop block; closing it keeps the server)
@@ -312,12 +323,15 @@ ${c.bold("Commands:")}
312
323
  --cell <m> (0.25) --cell-height <m> (0.1) --tile <cells> (48)
313
324
  --obj also write <n>.navmesh.obj to inspect
314
325
  --frames <n> load headroom (900) --logs host output
315
- shaders [compile|update] The project's custom shaders (.mat) and their local compile:
326
+ shaders [compile|update|new] The project's custom shaders (.mat) and their local compile:
316
327
  no args lists the shaders + their compile state; compile
317
328
  compiles them all into .lecodes/shaders (the same pass
318
329
  'lecodes dev' runs on the fly — via Filament's matc,
319
330
  downloaded on first use to ~/.lecodes/matc); update
320
- downloads the newest matc (run after an engine upgrade)
331
+ downloads the newest matc (run after an engine upgrade);
332
+ new <name> [--ref lit] [--dir d] [--force] writes a fresh
333
+ .mat + .material.ts: the starter, or a copy of the engine's
334
+ own lit / unlit / particles / … source to start from
321
335
  scene [path] LeCodes Scene Editor — visually edit a .scene.ts (tree, inspector,
322
336
  gizmo) on local files (needs the optional 'lecodes-3d-editor' package;
323
337
  works inside a cloned project or standalone in any folder)
@@ -443,7 +457,10 @@ const printVersion = () => {
443
457
  const rows: [label: string, value: string][] = [["lecodes-cli", readVersion()]]
444
458
  for (const [name, probe] of OPTIONAL_PEERS) {
445
459
  const version = peerVersion(name, probe)
446
- rows.push([name, version ?? c.dim("not installed")])
460
+ const where = peerSource(name, probe)
461
+ rows.push([name, version
462
+ ? (where === "prefix" ? `${version} ${c.dim("(~/.lecodes/peers)")}` : version)
463
+ : c.dim("not installed — installed on first use")])
447
464
  }
448
465
  const desktopVersion = selectedDesktopVersion()
449
466
  rows.push(["lecodes-desktop", installedDesktopVersions().includes(desktopVersion)
@@ -461,6 +478,9 @@ const main = async () => {
461
478
  // The project's .env (LECODES_* / CREATOR_* next to app.json) — before any command reads the
462
479
  // environment; the real environment keeps precedence. See projectEnv.ts.
463
480
  loadProjectEnv()
481
+ // `--yes` / `-y` anywhere on the line also pre-approves first-use installs of optional peers
482
+ // (peerInstall.ts) — the same knob LECODES_YES=1 gives CI.
483
+ if (argv.includes("--yes") || argv.includes("-y")) process.env.LECODES_YES = "1"
464
484
 
465
485
  if (!command || command === "help" || command === "--help" || command === "-h") {
466
486
  log(HELP)
package/src/matcTool.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
3
  import { join, resolve } from "node:path"
4
- import { downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
4
+ import { downloadReleaseArchive, fetchReleaseProduct, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
5
5
  import { CliError, note } from "./util"
6
6
 
7
7
  /*
@@ -16,8 +16,11 @@ import { CliError, note } from "./util"
16
16
  * hosts ship). Cache: ~/.lecodes/matc/matc-<version>/. LECODES_MATC overrides resolution entirely
17
17
  * (local dev: point it at packages/desktop/build/creator-gl/filament/tools/matc/matc.exe).
18
18
  *
19
- * No version pin: a filamat's compatibility is tied to the vendored Filament, which changes
20
- * rarely — resolution takes the newest cached copy, else downloads `latest`. `lecodes shaders
19
+ * No version pin: a filamat's compatibility is tied to the vendored Filament, which changes rarely —
20
+ * resolution takes the newest cached copy, else downloads the newest release PUBLISHED FOR THIS
21
+ * PLATFORM (releases.ts newestForPlatform), NOT the product's `latest`: a release only ships the
22
+ * platforms whose machine ran it, so `latest` is often Windows-only and every other platform would
23
+ * read "not published" with a perfectly good older build sitting right there. `lecodes shaders
21
24
  * update` moves an existing cache forward after an engine upgrade.
22
25
  *
23
26
  * Platforms: windows-x64, linux-x64, macos-arm64. On Windows/Linux matc rides the DESKTOP build
@@ -80,15 +83,21 @@ export const installedMatcVersions = (): string[] => {
80
83
  export const fetchMatcManifest = (): Promise<{ latest: string, versions: ManifestVersion[] }> =>
81
84
  fetchReleaseProduct("matc", "matc (shader compiler)")
82
85
 
83
- const downloadMatc = async (version: string, versions: ManifestVersion[], dest: string): Promise<void> => {
86
+ /** The published matc this platform should get: the newest version carrying a build for it (see
87
+ * newestForPlatform — a release often ships one platform, so `latest` is the wrong question). */
88
+ const pickMatc = (versions: ManifestVersion[]): { version: string, file: ManifestFile } => {
84
89
  const key = platformKey()
85
- const file = versions.find((v) => v.version === version)?.files[key]
86
- if (!file) {
90
+ const hit = newestForPlatform(versions, key)
91
+ if (!hit) {
87
92
  throw new CliError(
88
- `matc ${version} [${key}] isn't published — push the project once so shaders compile on the ` +
93
+ `matc isn't published for ${key} — push the project once so shaders compile on the ` +
89
94
  `server, or set LECODES_MATC to a locally built matc.`,
90
95
  )
91
96
  }
97
+ return hit
98
+ }
99
+
100
+ const downloadMatc = async (version: string, file: ManifestFile, dest: string): Promise<void> => {
92
101
  note(`Downloading the shader compiler (matc ${version}, ${(file.size / 1024 / 1024).toFixed(1)} MB)…`)
93
102
  await downloadReleaseArchive(file, {
94
103
  cacheRoot: cacheRoot(),
@@ -109,29 +118,32 @@ export const resolveMatcExeCached = (): string | null => {
109
118
  return cached ? findMatc(join(cacheRoot(), `matc-${cached}`)) : null
110
119
  }
111
120
 
112
- /** The matc binary: LECODES_MATC override → newest cached version → download the latest release.
113
- * Throws CliError when unavailable (callers degrade to the push-once server-compile warning). */
121
+ /** The matc binary: LECODES_MATC override → newest cached version → download the newest release
122
+ * published for this platform. Throws CliError when unavailable (callers degrade to the push-once
123
+ * server-compile warning). */
114
124
  export const resolveMatcExe = async (): Promise<string> => {
115
125
  const cached = resolveMatcExeCached()
116
126
  if (cached) return cached
117
- const { latest, versions } = await fetchMatcManifest()
118
- const dir = join(cacheRoot(), `matc-${latest}`)
119
- await downloadMatc(latest, versions, dir)
127
+ const { versions } = await fetchMatcManifest()
128
+ const { version, file } = pickMatc(versions)
129
+ const dir = join(cacheRoot(), `matc-${version}`)
130
+ await downloadMatc(version, file, dir)
120
131
  const exe = findMatc(dir)
121
132
  if (!exe) throw new CliError(`matc cache at ${dir} is broken — delete the folder and retry.`)
122
133
  return exe
123
134
  }
124
135
 
125
- /** `lecodes shaders update`: download the newest published matc (cached versions stay; the newest
126
- * wins resolution). Run it after an engine upgrade so local shader artifacts match the hosts. */
136
+ /** `lecodes shaders update`: download the newest matc published for this platform (cached versions
137
+ * stay; the newest wins resolution). Run it after an engine upgrade so local artifacts match the hosts. */
127
138
  export const updateMatc = async (): Promise<void> => {
128
- const { latest, versions } = await fetchMatcManifest()
139
+ const { versions } = await fetchMatcManifest()
140
+ const { version, file } = pickMatc(versions)
129
141
  const before = installedMatcVersions()[0]
130
- const dir = join(cacheRoot(), `matc-${latest}`)
142
+ const dir = join(cacheRoot(), `matc-${version}`)
131
143
  if (existsSync(dir) && findMatc(dir) !== null) {
132
- note(`Already up to date: matc ${latest}.`)
144
+ note(`Already up to date: matc ${version}.`)
133
145
  return
134
146
  }
135
- await downloadMatc(latest, versions, dir)
136
- note(before ? `matc updated to ${latest} (was ${before}).` : `matc ${latest} installed.`)
147
+ await downloadMatc(version, file, dir)
148
+ note(before ? `matc updated to ${version} (was ${before}).` : `matc ${version} installed.`)
137
149
  }
@@ -0,0 +1,160 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { pathToFileURL } from "node:url"
5
+ import pkg from "../package.json"
6
+ import { peerDirInPrefix, peersDir, peerVersionGlobal } from "./peers"
7
+ import { CliError, c, confirm, log, note } from "./util"
8
+
9
+ /*
10
+ * Peers install themselves on first use. `lecodes render` on a machine with only the CLI asks once,
11
+ * then puts lecodes-renderer (+ @napi-rs/canvas for PNG) into ~/.lecodes/peers and loads it from
12
+ * there — the same pattern as matc and the desktop host (downloaded into ~/.lecodes on first use),
13
+ * applied to npm packages. Why a private prefix and not `npm install -g`: no sudo / system prefix
14
+ * to guess, and the runtime-set peer is installed at EXACTLY this CLI's version — the compiler and the
15
+ * headless host are one ABI set, so a global renderer that drifted behind the CLI is the very failure
16
+ * this removes. A newer CLI re-installs its own version on the next use (peerSpec → `lecodes update`).
17
+ *
18
+ * Consent: an interactive terminal is asked (default yes); `--yes` / `-y` on any command or
19
+ * LECODES_YES=1 (CI) skips the question; a non-interactive run without either fails with the exact
20
+ * command to run by hand. Installer: npm if on PATH, else bun; neither → a clear error (the standalone
21
+ * binary on a machine without Node can't install packages — it says so).
22
+ *
23
+ * Search order when a command needs a peer: the bare specifier (global / the workspace — what the
24
+ * user installed by hand always wins) → the prefix → install into the prefix, then load from there.
25
+ */
26
+
27
+ const CANVAS_RANGE_FALLBACK = "^0.1.65"
28
+
29
+ const readJson = (path: string): any => { try { return JSON.parse(readFileSync(path, "utf8")) } catch { return null } }
30
+
31
+ /** The npm spec a peer installs at: the renderer at exactly this CLI's version (one ABI set), canvas
32
+ * at the range the installed renderer declares, the editors / assets at this CLI's peer ranges. */
33
+ export const peerSpec = (name: string): string => {
34
+ if (name === "lecodes-renderer") return `lecodes-renderer@${pkg.version}`
35
+ if (name === "@napi-rs/canvas") {
36
+ const renderer = readJson(join(peerDirInPrefix("lecodes-renderer"), "package.json"))
37
+ const range = renderer?.peerDependencies?.["@napi-rs/canvas"] ?? renderer?.optionalDependencies?.["@napi-rs/canvas"] ?? CANVAS_RANGE_FALLBACK
38
+ return `@napi-rs/canvas@${range}`
39
+ }
40
+ const range = (pkg.peerDependencies as Record<string, string> | undefined)?.[name]
41
+ return range ? `${name}@${range}` : `${name}@latest`
42
+ }
43
+
44
+ /** Peers that only make sense together: the renderer's PNG output needs canvas next to it. */
45
+ const companions = (name: string): string[] => name === "lecodes-renderer" ? ["lecodes-renderer", "@napi-rs/canvas"] : [name]
46
+
47
+ const onPath = (cmd: string): boolean => {
48
+ try { return spawnSync(cmd, ["--version"], { stdio: "ignore", shell: process.platform === "win32" }).status === 0 } catch { return false }
49
+ }
50
+ const pickInstaller = (): "npm" | "bun" | null => onPath("npm") ? "npm" : onPath("bun") ? "bun" : null
51
+
52
+ const manualCommand = (dir: string, specs: string[]) => `npm install --prefix "${dir}" ${specs.join(" ")}`
53
+
54
+ /**
55
+ * Install peers into the prefix. Asks for consent unless `opts.yes` / LECODES_YES / `--yes` say so;
56
+ * throws CliError with the manual command when it can't ask or the user declines.
57
+ */
58
+ export const installPeers = async (names: string[], why: string, opts: { yes?: boolean } = {}): Promise<void> => {
59
+ const dir = peersDir()
60
+ mkdirSync(dir, { recursive: true })
61
+ const manifest = join(dir, "package.json")
62
+ if (!existsSync(manifest)) {
63
+ writeFileSync(manifest, JSON.stringify({
64
+ name: "lecodes-peers", private: true,
65
+ description: "Optional lecodes-cli peers, installed on first use. Safe to delete — the CLI re-installs what a command needs.",
66
+ }, null, 2) + "\n")
67
+ }
68
+ // The renderer must be in place before canvas's range can be read off it — install in order.
69
+ const ordered = [...names].sort((a, b) => (a === "lecodes-renderer" ? -1 : b === "lecodes-renderer" ? 1 : 0))
70
+ const specsNow = ordered.map(peerSpec)
71
+
72
+ const yes = opts.yes === true || !!process.env.LECODES_YES
73
+ if (!yes) {
74
+ const interactive = process.stdin.isTTY && process.stdout.isTTY
75
+ if (!interactive) {
76
+ throw new CliError(`${why} needs ${names.join(" + ")}, which isn't installed. Re-run with --yes to install it into ${dir}, or install by hand: ${manualCommand(dir, specsNow)}`)
77
+ }
78
+ log(`${why} needs ${c.bold(names.join(" + "))}.`)
79
+ log(c.dim(`It installs once into ${dir} (no admin rights); a newer CLI re-installs its own version.`))
80
+ if (!(await confirm("Install now?", true))) {
81
+ throw new CliError(`Not installed. When you want it: ${manualCommand(dir, specsNow)}`)
82
+ }
83
+ }
84
+
85
+ const tool = pickInstaller()
86
+ if (!tool) throw new CliError(`Neither npm nor bun is on PATH, so ${names.join(" + ")} can't be installed automatically. Install one, or run on a machine with Node: ${manualCommand(dir, specsNow)}`)
87
+
88
+ for (const name of ordered) {
89
+ const spec = peerSpec(name) // canvas's range comes from the renderer installed a step earlier
90
+ const argv = tool === "npm"
91
+ ? ["install", "--prefix", dir, "--no-audit", "--no-fund", "--loglevel=error", spec]
92
+ : ["add", "--cwd", dir, spec]
93
+ note(`$ ${tool} ${argv.join(" ")}`)
94
+ const r = spawnSync(tool, argv, { stdio: "inherit", shell: process.platform === "win32", timeout: 10 * 60_000 })
95
+ if (r.error) throw new CliError(`${tool} could not run: ${r.error.message}`)
96
+ if (r.status !== 0) throw new CliError(`${tool} exited with ${r.status ?? "a signal"} while installing ${spec}. By hand: ${manualCommand(dir, [spec])}`)
97
+ if (!existsSync(join(peerDirInPrefix(name), "package.json"))) throw new CliError(`${name} did not land in ${dir} — the installer finished but the package is missing`)
98
+ }
99
+ }
100
+
101
+ /** The file a package subpath points at, read from its exports map (string, or a conditions object —
102
+ * `import` / `node` / `default`), falling back to `main`. Absolute path. */
103
+ const resolveEntry = (pkgDir: string, subpath?: string): string => {
104
+ const manifest = readJson(join(pkgDir, "package.json"))
105
+ if (!manifest) throw new CliError(`${pkgDir} holds no package.json`)
106
+ const key = subpath ? `./${subpath}` : "."
107
+ const pick = (target: unknown): string | null => {
108
+ if (typeof target === "string") return target
109
+ if (target && typeof target === "object") {
110
+ const t = target as Record<string, unknown>
111
+ for (const cond of ["import", "node", "default", "require"]) if (cond in t) { const p = pick(t[cond]); if (p) return p }
112
+ }
113
+ return null
114
+ }
115
+ let rel: string | null = null
116
+ const exp = manifest.exports
117
+ if (typeof exp === "string") rel = key === "." ? exp : null
118
+ else if (exp && typeof exp === "object") rel = pick((exp as Record<string, unknown>)[key])
119
+ if (!rel && key === ".") rel = typeof manifest.main === "string" ? manifest.main : "index.js"
120
+ if (!rel) throw new CliError(`${manifest.name} has no export "${key}"`)
121
+ return join(pkgDir, rel)
122
+ }
123
+
124
+ const isNotFound = (e: unknown, spec: string): boolean => {
125
+ const code = (e as { code?: string })?.code
126
+ const msg = e instanceof Error ? e.message : String(e)
127
+ return (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || /Cannot find (module|package)/i.test(msg)) && msg.includes(spec.split("/")[0]!)
128
+ }
129
+
130
+ export type LoadPeerOptions = {
131
+ /** What is asking — names the command in the consent prompt / error ("lecodes render"). */
132
+ for: string
133
+ /** `false`: never install — return null when the peer is nowhere (opportunistic features). */
134
+ install?: boolean
135
+ }
136
+
137
+ /**
138
+ * Load an optional peer's subpath: the bare specifier first (global / workspace), then the CLI's
139
+ * prefix, else install it there (with its companions) and load. Returns null only with
140
+ * `install: false` and the peer absent.
141
+ */
142
+ export async function loadPeer<T>(name: string, subpath: string | undefined, opts: LoadPeerOptions & { install?: true }): Promise<T>
143
+ export async function loadPeer<T>(name: string, subpath: string | undefined, opts: LoadPeerOptions & { install: false }): Promise<T | null>
144
+ export async function loadPeer<T>(name: string, subpath: string | undefined, opts: LoadPeerOptions): Promise<T | null> {
145
+ const spec = subpath ? `${name}/${subpath}` : name
146
+ try {
147
+ return (await import(spec)) as T
148
+ } catch (e) {
149
+ if (!isNotFound(e, spec)) throw e
150
+ }
151
+ const dir = peerDirInPrefix(name)
152
+ if (!existsSync(join(dir, "package.json"))) {
153
+ if (opts.install === false) return null
154
+ // A global install of a DIFFERENT version than the probe expects would have resolved above; here
155
+ // the peer is nowhere. (Globally installed but broken → the error above was not "not found".)
156
+ if (peerVersionGlobal(name, spec) !== null) throw new CliError(`${name} is installed but "${spec}" can't be loaded from it — reinstall it: npm install -g ${peerSpec(name)}`)
157
+ await installPeers(companions(name), opts.for)
158
+ }
159
+ return (await import(pathToFileURL(resolveEntry(dir, subpath)).href)) as T
160
+ }