lecodes-cli 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/index.js +1065 -577
  2. package/package.json +7 -7
  3. package/runtime/materials/decal-relief.mat +178 -0
  4. package/runtime/materials/decal.mat +172 -0
  5. package/runtime/materials/lightmap-baked-lite.mat +175 -0
  6. package/runtime/materials/lightmap-baked.mat +176 -0
  7. package/runtime/materials/lightmap.mat +181 -0
  8. package/runtime/materials/lit.mat +43 -0
  9. package/runtime/materials/particles-quad.mat +179 -0
  10. package/runtime/materials/particles.mat +129 -0
  11. package/runtime/materials/shadow.mat +21 -0
  12. package/runtime/materials/terrain.mat +134 -0
  13. package/runtime/materials/unlit-transparent.mat +42 -0
  14. package/runtime/materials/unlit.mat +44 -0
  15. package/runtime/materials/video.mat +31 -0
  16. package/runtime/scene-harness.json +1 -1
  17. package/runtime/sdk-types.json +1 -1
  18. package/runtime/web/assets/{createViewerLite-BvHobVhp.js → createViewerLite-Ct_PZdof.js} +22 -22
  19. package/runtime/web/assets/{index-CfNCCBjN.js → index-BMt7AnC5.js} +1 -1
  20. package/runtime/web/embed.js +2 -2
  21. package/src/cmgenTool.ts +17 -9
  22. package/src/commands/appDesktop.ts +12 -2
  23. package/src/commands/appShared.ts +48 -0
  24. package/src/commands/assets.ts +3 -7
  25. package/src/commands/design.ts +9 -26
  26. package/src/commands/desktop.ts +3 -0
  27. package/src/commands/dev.ts +42 -0
  28. package/src/commands/projectTemplates.ts +13 -1
  29. package/src/commands/render.ts +4 -8
  30. package/src/commands/scene.ts +2 -6
  31. package/src/commands/shaders.ts +17 -1
  32. package/src/commands/shadersNew.ts +133 -0
  33. package/src/commands/thumbs.ts +3 -6
  34. package/src/commands/update.ts +42 -21
  35. package/src/desktopRenderer.ts +17 -0
  36. package/src/desktopScript.ts +1 -1
  37. package/src/dev/androidDev.ts +154 -0
  38. package/src/dev/devServer.ts +8 -2
  39. package/src/index.ts +21 -4
  40. package/src/matcTool.ts +31 -19
  41. package/src/peerInstall.ts +160 -0
  42. package/src/peers.ts +48 -14
  43. package/src/projectEnv.ts +4 -0
  44. package/src/releases.ts +16 -0
@@ -4,7 +4,8 @@ import { homedir } from "node:os"
4
4
  import { dirname, join, resolve } from "node:path"
5
5
  import pkg from "../../package.json"
6
6
  import { CliError, c, flagBool, log, note, type Args } from "../util"
7
- import { OPTIONAL_PEERS, peerVersion } from "../peers"
7
+ import { OPTIONAL_PEERS, peerSource, peerVersion } from "../peers"
8
+ import { installPeers } from "../peerInstall"
8
9
  import { updateDesktop } from "../desktopRenderer"
9
10
  import { updateMatc } from "../matcTool"
10
11
  import { types } from "./types"
@@ -20,10 +21,12 @@ import { app } from "./app"
20
21
  * 3. shaders recompile the project's .mat files with that matc (= `shaders compile`)
21
22
  * 4. types the IDE types (.lecodes/types + tsconfig) from the server (= `types`)
22
23
  * 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)
24
+ * 6. cli the npm installs — every installed optional peer (lecodes-renderer, 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)
27
30
  *
28
31
  * Every step is the existing subcommand, so nothing here can drift from what the individual
29
32
  * commands do; this only sequences them and keeps going when one fails (the summary says which).
@@ -148,35 +151,53 @@ export const update = async (args: Args) => {
148
151
  await run("cli", "lecodes-cli + its optional peers (global npm installs)", async () => {
149
152
  if (flagBool(args, "no-cli")) return ["skipped", "--no-cli"]
150
153
  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 }[] = []
154
+ // Where each peer lives decides how it moves: the CLI's own prefix (~/.lecodes/peers, filled on
155
+ // first use) is re-installed by peerInstall — the renderer there tracks THIS CLI's version, not
156
+ // npm's latest, because compiler and headless host are one ABI set; a global install
157
+ // (`npm install -g`, the old way) moves through npm like before.
158
+ const rows: { name: string, installed: string | null, latest: string | null, where: "global" | "prefix" | null }[] = []
152
159
  for (const [name, probe] of OPTIONAL_PEERS) {
160
+ const where = peerSource(name, probe)
153
161
  const installed = peerVersion(name, probe)
154
- rows.push({ name, installed, latest: installed ? await npmLatest(name) : null })
162
+ const latest = !installed ? null
163
+ : where === "prefix" && name === "lecodes-renderer" ? pkg.version
164
+ : await npmLatest(name)
165
+ rows.push({ name, installed, latest, where })
155
166
  }
156
- rows.push({ name: "lecodes-cli", installed: pkg.version, latest: await npmLatest("lecodes-cli") })
167
+ rows.push({ name: "lecodes-cli", installed: pkg.version, latest: await npmLatest("lecodes-cli"), where: "global" })
157
168
  if (rows.every((r) => r.latest === null)) return ["skipped", "npm unreachable"]
158
169
  const behind = rows.filter((r) => r.installed && r.latest && semverNewer(r.latest, r.installed) > 0)
159
170
  for (const r of rows) {
160
- const state = !r.installed ? c.dim("not installed (optional — npm install -g " + r.name + ")")
171
+ const state = !r.installed ? c.dim("not installed (optional — installed on first use)")
161
172
  : !r.latest ? c.dim("(npm: unknown)")
162
173
  : behind.includes(r) ? c.yellow("→ " + r.latest)
163
174
  : c.dim("(up to date)")
164
- log(" " + r.name.padEnd(18) + " " + (r.installed ?? "").padEnd(8) + " " + state)
175
+ log(" " + r.name.padEnd(18) + " " + (r.installed ?? "").padEnd(8) + " " + (r.where === "prefix" ? c.dim("peers ") : "") + state)
165
176
  }
166
177
  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")) {
178
+ const prefixBehind = behind.filter((r) => r.where === "prefix")
179
+ const globalSpecs = behind.filter((r) => r.where !== "prefix").map((r) => r.name + "@latest")
180
+ if (flagBool(args, "check")) {
181
+ return ["updated", behind.length + " behind — run: lecodes update" + (globalSpecs.length ? " (global: npm install -g " + globalSpecs.join(" ") + ")" : "")]
182
+ }
183
+ const done: string[] = []
184
+ if (prefixBehind.length) {
185
+ await installPeers(prefixBehind.map((r) => r.name), "lecodes update", { yes: true })
186
+ done.push(...prefixBehind.map((r) => r.name))
187
+ }
188
+ if (STANDALONE && globalSpecs.some((s) => s.startsWith("lecodes-cli@"))) {
170
189
  note("this is the standalone build — the CLI updates by re-installing the release archive, not through npm; peers still go through npm")
171
190
  }
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)" : "")]
191
+ const toInstall = STANDALONE ? globalSpecs.filter((s) => !s.startsWith("lecodes-cli@")) : globalSpecs
192
+ if (toInstall.length) {
193
+ log(" " + c.dim("$") + " npm install -g " + toInstall.join(" "))
194
+ const r = spawnSync("npm", ["install", "-g", ...toInstall], { stdio: "inherit", shell: true })
195
+ if (r.status !== 0) throw new CliError("npm install -g exited with " + (r.status ?? "a signal"))
196
+ done.push(...toInstall.map((s) => s.replace("@latest", "")))
197
+ }
198
+ if (done.length === 0) return ["skipped", "only the standalone CLI itself is behind — download the release"]
199
+ const cliToo = done.includes("lecodes-cli")
200
+ return ["updated", done.join(", ") + " → latest" + (cliToo ? " (the new CLI runs from your next command)" : "")]
180
201
  })
181
202
 
182
203
  // Summary — one line per step so a scrolled-away failure can't hide.
@@ -284,6 +284,10 @@ export type DesktopWindowOpts = {
284
284
  minHeight?: number
285
285
  resizable?: boolean
286
286
  title?: string
287
+ /** localStorage scope (CREATOR_STORAGE_SCOPE, host ≥1.5.0): the app's stable identity — app.json
288
+ * `name` — so a `desktop run` keeps its saved state across runs (the host would otherwise key the
289
+ * storage by the launch path, which is a fresh temp dir every run). */
290
+ storageScope?: string
287
291
  /** Borderless fullscreen on the primary monitor (host ≥1.3.0; older hosts ignore the env). */
288
292
  fullscreen?: boolean
289
293
  /** Display scale: a fixed density (0.5–4) or "fit" (derive from design frame vs monitor/work
@@ -293,6 +297,12 @@ export type DesktopWindowOpts = {
293
297
  renderScale?: number
294
298
  /** 3D buffer cap [W, H] in px (host ≥1.4.0) — e.g. [1920, 1080] keeps a 4K fullscreen at 1080p 3D. */
295
299
  maxRenderSize?: [number, number]
300
+ /** The mouse button acts as a finger — click-drag scrolls/flings like touch (CREATOR_MOUSE_TOUCH;
301
+ * every host reads the env). A CREATOR_MOUSE_TOUCH already in the environment (shell / .env) wins. */
302
+ mouseEmulateTouch?: boolean
303
+ /** On-screen keyboard for every input (app.json desktop.virtualKeyboard, host ≥1.5.5): `true` or
304
+ * the block, forwarded verbatim as CREATOR_VIRTUAL_KEYBOARD JSON. A set env wins, like the mouse knob. */
305
+ virtualKeyboard?: boolean | object
296
306
  /** Draw the host's frame counter in the corner (host ≥1.5.0; older hosts ignore the env). Costs
297
307
  * nothing to leave off and next to nothing on — the digits are rectangles, no UI behind them. */
298
308
  fps?: boolean
@@ -319,6 +329,7 @@ const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
319
329
  if (width && height) env.CREATOR_WINDOW_SIZE = `${width}x${height}`
320
330
  if (opts.minWidth && opts.minHeight) env.CREATOR_MIN_WINDOW_SIZE = `${opts.minWidth}x${opts.minHeight}`
321
331
  if (opts.title) env.CREATOR_WINDOW_TITLE = opts.title
332
+ if (opts.storageScope) env.CREATOR_STORAGE_SCOPE = opts.storageScope
322
333
  if (opts.resizable === false) env.CREATOR_WINDOW_FIXED = "1"
323
334
  if (opts.fullscreen !== undefined) env.CREATOR_WINDOW_FULLSCREEN = opts.fullscreen ? "1" : "0"
324
335
  if (opts.scale === "fit") env.CREATOR_UI_SCALE = "fit"
@@ -328,6 +339,12 @@ const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
328
339
  env.CREATOR_RENDER_MAX = `${Math.round(opts.maxRenderSize[0])}x${Math.round(opts.maxRenderSize[1])}`
329
340
  }
330
341
  if (opts.fps) env.CREATOR_FPS = "1"
342
+ if (opts.mouseEmulateTouch !== undefined && process.env.CREATOR_MOUSE_TOUCH === undefined) {
343
+ env.CREATOR_MOUSE_TOUCH = opts.mouseEmulateTouch ? "1" : "0"
344
+ }
345
+ if (opts.virtualKeyboard !== undefined && process.env.CREATOR_VIRTUAL_KEYBOARD === undefined) {
346
+ env.CREATOR_VIRTUAL_KEYBOARD = typeof opts.virtualKeyboard === "boolean" ? (opts.virtualKeyboard ? "1" : "0") : JSON.stringify(opts.virtualKeyboard)
347
+ }
331
348
  return env
332
349
  }
333
350
 
@@ -40,7 +40,7 @@ export type DesktopPlan = {
40
40
  }
41
41
 
42
42
  const ACTIONS = ["tap", "type", "fill", "scroll", "wait", "frames", "drag", "hold", "key", "keyDown", "keyUp", "look", "mouseTo", "wheel", "stick", "gamepad", "camera", "view"] as const
43
- const SEMANTIC_EXPECT = ["text", "notText", "node", "notNode", "screen", "value"]
43
+ const SEMANTIC_EXPECT = ["text", "notText", "node", "notNode", "screen", "value", "audio"]
44
44
  const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
45
45
  const isVec3 = (v: unknown): v is [number, number, number] => Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === "number")
46
46
 
@@ -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
+ }
@@ -195,8 +195,8 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
195
195
  const t = url.searchParams.get("t")
196
196
  return t === "poll" ? "poll" : t === "web" ? "web" : "ws"
197
197
  }
198
- const withPrelude = (js: string, url: URL, v: number) => {
199
- const transport = transportOf(url)
198
+ const withPrelude = (js: string, url: URL, v: number, forced?: "poll") => {
199
+ const transport = forced ?? transportOf(url)
200
200
  return transport === "web" ? js : js + "\n" + renderPrelude({ origin, base, transport, v })
201
201
  }
202
202
  const webDir = options.webDir ?? null
@@ -212,6 +212,12 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
212
212
  if (route === "/dev.js") {
213
213
  return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1))
214
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"))
220
+ }
215
221
  if (route === "/bundle.js") {
216
222
  if (bundleJs === null) return sendText(res, 503, "text/plain", "No successful compile yet — check the terminal.")
217
223
  res.setHeader("x-lecodes-v", String(v)) // the browser runner learns the version it runs
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,14 @@ ${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.
123
131
  --web also open the app in the browser (Web Lite host:
124
132
  same bundle + hot reload, camera/map plugins
125
133
  have web twins; the /web/ URL is served always)
@@ -315,12 +323,15 @@ ${c.bold("Commands:")}
315
323
  --cell <m> (0.25) --cell-height <m> (0.1) --tile <cells> (48)
316
324
  --obj also write <n>.navmesh.obj to inspect
317
325
  --frames <n> load headroom (900) --logs host output
318
- 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:
319
327
  no args lists the shaders + their compile state; compile
320
328
  compiles them all into .lecodes/shaders (the same pass
321
329
  'lecodes dev' runs on the fly — via Filament's matc,
322
330
  downloaded on first use to ~/.lecodes/matc); update
323
- 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
324
335
  scene [path] LeCodes Scene Editor — visually edit a .scene.ts (tree, inspector,
325
336
  gizmo) on local files (needs the optional 'lecodes-3d-editor' package;
326
337
  works inside a cloned project or standalone in any folder)
@@ -446,7 +457,10 @@ const printVersion = () => {
446
457
  const rows: [label: string, value: string][] = [["lecodes-cli", readVersion()]]
447
458
  for (const [name, probe] of OPTIONAL_PEERS) {
448
459
  const version = peerVersion(name, probe)
449
- 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")])
450
464
  }
451
465
  const desktopVersion = selectedDesktopVersion()
452
466
  rows.push(["lecodes-desktop", installedDesktopVersions().includes(desktopVersion)
@@ -464,6 +478,9 @@ const main = async () => {
464
478
  // The project's .env (LECODES_* / CREATOR_* next to app.json) — before any command reads the
465
479
  // environment; the real environment keeps precedence. See projectEnv.ts.
466
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"
467
484
 
468
485
  if (!command || command === "help" || command === "--help" || command === "-h") {
469
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
  }