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
package/src/cmgenTool.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
  /*
@@ -20,7 +20,8 @@ import { CliError, note } from "./util"
20
20
  * stays network-free and still works standalone against a local build.
21
21
  *
22
22
  * No version pin, same reasoning as matc: the KTX1 layout is tied to the vendored Filament and
23
- * changes rarely, so resolution takes the newest cached copy, else downloads `latest`.
23
+ * changes rarely, so resolution takes the newest cached copy, else downloads the newest release
24
+ * PUBLISHED FOR THIS PLATFORM (see releases.ts newestForPlatform).
24
25
  */
25
26
 
26
27
  const exeName = () => (process.platform === "win32" ? "cmgen.exe" : "cmgen")
@@ -63,15 +64,21 @@ export const installedCmgenVersions = (): string[] => {
63
64
  export const fetchCmgenManifest = (): Promise<{ latest: string, versions: ManifestVersion[] }> =>
64
65
  fetchReleaseProduct("cmgen", "cmgen (environment tool)")
65
66
 
66
- const downloadCmgen = async (version: string, versions: ManifestVersion[], dest: string): Promise<void> => {
67
+ /** The published cmgen this platform should get: the newest version carrying a build for it (see
68
+ * newestForPlatform — a release often ships one platform, so `latest` is the wrong question). */
69
+ const pickCmgen = (versions: ManifestVersion[]): { version: string, file: ManifestFile } => {
67
70
  const key = platformKey()
68
- const file = versions.find((v) => v.version === version)?.files[key]
69
- if (!file) {
71
+ const hit = newestForPlatform(versions, key)
72
+ if (!hit) {
70
73
  throw new CliError(
71
- `cmgen ${version} [${key}] isn't published — build filament's host tools locally and point ` +
74
+ `cmgen isn't published for ${key} — build filament's host tools locally and point ` +
72
75
  `LECODES_CMGEN at the binary.`,
73
76
  )
74
77
  }
78
+ return hit
79
+ }
80
+
81
+ const downloadCmgen = async (version: string, file: ManifestFile, dest: string): Promise<void> => {
75
82
  note(`Downloading the environment tool (cmgen ${version}, ${(file.size / 1024 / 1024).toFixed(1)} MB)…`)
76
83
  await downloadReleaseArchive(file, {
77
84
  cacheRoot: cacheRoot(),
@@ -95,9 +102,10 @@ export const resolveCmgenExeCached = (): string | null => {
95
102
  export const resolveCmgenExe = async (): Promise<string> => {
96
103
  const cached = resolveCmgenExeCached()
97
104
  if (cached) return cached
98
- const { latest, versions } = await fetchCmgenManifest()
99
- const dir = join(cacheRoot(), `cmgen-${latest}`)
100
- await downloadCmgen(latest, versions, dir)
105
+ const { versions } = await fetchCmgenManifest()
106
+ const { version, file } = pickCmgen(versions)
107
+ const dir = join(cacheRoot(), `cmgen-${version}`)
108
+ await downloadCmgen(version, file, dir)
101
109
  const exe = findCmgenIn(dir)
102
110
  if (!exe) throw new CliError(`cmgen cache at ${dir} is broken — delete the folder and retry.`)
103
111
  return exe
@@ -199,8 +199,18 @@ export const buildDesktop = async (args: Args) => {
199
199
  note(` assets: ${assetCount} files, ${mb(assetBytes)} → ${ASSETS_DIR}/`)
200
200
 
201
201
  // 3) Pruned app.json — the host (≥1.3.0) reads name + desktop.window next to app.js, so the
202
- // folder carries its own window config (title/size/min/resizable/fullscreen).
203
- writeJson(join(out, "app.json"), { name, ...(config.desktop ? { desktop: { window: config.desktop.window } } : {}) })
202
+ // folder carries its own window config (title/size/min/resizable/fullscreen); a newer host also
203
+ // reads desktop.mouseEmulateTouch + desktop.virtualKeyboard there (an older one skips the
204
+ // unknown fields).
205
+ const desktop = config.desktop
206
+ writeJson(join(out, "app.json"), {
207
+ name,
208
+ ...(desktop ? { desktop: {
209
+ window: desktop.window,
210
+ ...(desktop.mouseEmulateTouch !== undefined ? { mouseEmulateTouch: desktop.mouseEmulateTouch } : {}),
211
+ ...(desktop.virtualKeyboard !== undefined ? { virtualKeyboard: desktop.virtualKeyboard } : {}),
212
+ } } : {}),
213
+ })
204
214
 
205
215
  // 4) Window/taskbar icon (host ≥1.3.0 loads app.ico next to the exe at runtime).
206
216
  if (config.icon) {
@@ -77,12 +77,60 @@ export interface AppConfig {
77
77
  /** 3D buffer cap in px, e.g. [1920, 1080]: on a bigger window/monitor the 3D renders at most
78
78
  * this size (aspect kept) — the per-machine answer to "fullscreen on a 4K display is slow". */
79
79
  maxRenderSize?: [number, number],
80
+ /** The mouse button acts as a finger: a left click-drag scrolls + flings like touch (the wheel
81
+ * always scrolls, clicks are unaffected; a button inside a scrollable then gets the touch-style
82
+ * press feedback delay). For testing the touch experience without a touchscreen, or an app
83
+ * driven by a mouse-like device. `desktop run` forwards it as CREATOR_MOUSE_TOUCH (a set env
84
+ * wins); a built folder's host reads it from the staged app.json. Headless render/test ignore it. */
85
+ mouseEmulateTouch?: boolean,
86
+ /** On-screen keyboard for EVERY input/textarea (touch kiosks, a screen without a keyboard).
87
+ * `true` or a block; `desktop run` forwards it as CREATOR_VIRTUAL_KEYBOARD (a set env wins),
88
+ * a built folder's host reads it from the staged app.json. Headless render/test ignore it. */
89
+ virtualKeyboard?: boolean | DesktopVirtualKeyboard,
80
90
  /** The macOS .app build (`lecodes app build desktop` on a Mac) — identity + signing +
81
91
  * notarization. See appDesktopMac.ts for the whole story. */
82
92
  macos?: MacDesktopConfig,
83
93
  }
84
94
  }
85
95
 
96
+ /** One style slot of the desktop on-screen keyboard — the SDK's style words, the subset the drawn
97
+ * keyboard reads. Unset fields keep the theme preset. */
98
+ export interface DesktopKeyboardSlot {
99
+ backgroundColor?: string
100
+ color?: string
101
+ borderRadius?: number
102
+ fontSize?: number
103
+ /** A family the app registered with `font()` / `Fonts`, so labels match the UI. */
104
+ fontFamily?: string
105
+ /** `board` only: band inset / key spacing (logical px). */
106
+ padding?: number
107
+ gap?: number
108
+ }
109
+
110
+ /** app.json `desktop.virtualKeyboard` as a block. */
111
+ export interface DesktopVirtualKeyboard {
112
+ /** Off/on; an object without it counts as on. */
113
+ enabled?: boolean
114
+ /** Layouts in order, the first is the start one; the language key appears with two or more.
115
+ * Shipped: "en", "ru". */
116
+ layouts?: ("en" | "ru")[]
117
+ /** Band height: ≤ 1 = a fraction of the window (default 0.32), > 1 = logical px. */
118
+ height?: number
119
+ /** Cap on the KEY AREA's width in logical px — on a wide kiosk the keys would otherwise stretch
120
+ * across the whole window. The band itself always spans the window; the keys sit centred in it. */
121
+ maxWidth?: number
122
+ /** Colour preset the style slots override. */
123
+ theme?: "dark" | "light"
124
+ style?: {
125
+ board?: DesktopKeyboardSlot
126
+ key?: DesktopKeyboardSlot
127
+ /** A held key (and an active shift). Inherits `key` first. */
128
+ keyPressed?: DesktopKeyboardSlot
129
+ /** Shift / backspace / enter / page keys. Inherits `key` first. */
130
+ keySpecial?: DesktopKeyboardSlot
131
+ }
132
+ }
133
+
86
134
  /** app.json `desktop.macos`. Only NAMES live here (identity, keychain profile) — the certificate
87
135
  * and the notarization password stay in the keychain. */
88
136
  export interface MacDesktopConfig {
@@ -1,5 +1,6 @@
1
- import { CliError, type Args } from "../util"
1
+ import { type Args } from "../util"
2
2
  import { resolveCmgenExe } from "../cmgenTool"
3
+ import { loadPeer } from "../peerInstall"
3
4
 
4
5
  /*
5
6
  * `lecodes assets <convert|probe|doctor|sky> …` — the LeCodes asset pipeline (packages/lecodes-assets):
@@ -9,12 +10,7 @@ import { resolveCmgenExe } from "../cmgenTool"
9
10
  * forwarded untouched (`assets` gets `_raw` from main). See docs/animation-plan.md §2.9.
10
11
  */
11
12
  export const assets = async (args: Args & { _raw?: string[] }) => {
12
- let mod: typeof import("lecodes-assets/cli")
13
- try {
14
- mod = await import("lecodes-assets/cli")
15
- } catch {
16
- throw new CliError("The asset pipeline isn't installed. Run: npm install -g lecodes-assets")
17
- }
13
+ const mod = await loadPeer<typeof import("lecodes-assets/cli")>("lecodes-assets", "cli", { for: "lecodes assets" })
18
14
  const raw = args._raw ?? args._
19
15
  // `sky` shells out to filament's cmgen, which the asset package deliberately never downloads
20
16
  // itself (it stays network-free and usable standalone). The CLI owns release artifacts, so it
@@ -5,6 +5,7 @@ import { compileDesignScreen } from "../compile/designCompile"
5
5
  import { openBrowser } from "../browserAuth"
6
6
  import { CLAUDE_MD, DESIGN_MACROS_DTS, HOME_SCREEN_TS, META_JSON, META_JSON_DESKTOP, README_HINT, SPEC_MD, TABS_TS, TOKENS_TS } from "./designTemplates"
7
7
  import { CliError, c, flagBool, flagStr, log, logErr, note, warnErr, type Args } from "../util"
8
+ import { loadPeer } from "../peerInstall"
8
9
  import { loadConfig, normalizeApiUrl, requireApiUrl, requireToken } from "../config"
9
10
  import {
10
11
  createDesignComment, deleteDesignComment, deleteDesignCommentMessage, disableDesignShare,
@@ -87,12 +88,7 @@ const loadDesignServer = async (
87
88
  if (!existsSync(ctx.designDir)) {
88
89
  throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`)
89
90
  }
90
- let mod: typeof import("lecodes-design/server")
91
- try {
92
- mod = await import("lecodes-design/server")
93
- } catch {
94
- throw new CliError("The design canvas isn't installed. Run: npm install -g lecodes-design")
95
- }
91
+ const mod = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design" })
96
92
  const missing = needs.filter((name) => typeof mod[name] !== "function")
97
93
  if (missing.length) {
98
94
  throw new CliError(
@@ -236,23 +232,14 @@ const snapshot = async (ctx: DesignContext, args: Args) => {
236
232
  const allStates = flagBool(args, "all-states")
237
233
  if (stateFlag !== undefined && allStates) throw new CliError("Use either --state <name> or --all-states, not both.")
238
234
 
239
- let renderer: typeof import("lecodes-renderer/headless")
240
- try {
241
- renderer = await import("lecodes-renderer/headless")
242
- } catch {
243
- throw new CliError("The renderer isn't installed. Run: npm install -g lecodes-renderer")
244
- }
235
+ const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design render" })
245
236
 
246
237
  // Enumerating / validating states parses the screen source — that discovery lives in the design
247
238
  // package. The canonical-only path (no flag) needs no discovery, so base snapshot stays lean.
248
239
  let discover: ((source: string) => { states: string[], canonical: string }) | null = null
249
240
  if (stateFlag !== undefined || allStates) {
250
- try {
251
- const ds = await import("lecodes-design/server")
252
- discover = (src) => { const d = ds.discoverScreenStates(src); return { states: d.states, canonical: d.canonical } }
253
- } catch {
254
- throw new CliError("Rendering specific states needs the design canvas. Run: npm install -g lecodes-design")
255
- }
241
+ const ds = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design render --state" })
242
+ discover = (src) => { const d = ds.discoverScreenStates(src); return { states: d.states, canonical: d.canonical } }
256
243
  }
257
244
 
258
245
  // A stuck screen (runaway loop) shouldn't hang the CLI forever.
@@ -330,8 +317,8 @@ const serve = async (ctx: DesignContext, args: Args) => {
330
317
 
331
318
  // The MCP render_screen tool renders headless through lecodes-renderer, injected here so the
332
319
  // design server keeps no dependency on it. Absent renderer → the tool returns an install hint.
333
- let renderer: typeof import("lecodes-renderer/headless") | null = null
334
- try { renderer = await import("lecodes-renderer/headless") } catch { renderer = null }
320
+ // Opportunistic: never triggers an install from inside the MCP server (the tool reports the hint).
321
+ const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design (render_screen)", install: false })
335
322
  const renderScreen = renderer
336
323
  ? async (id: string, format: "json" | "png" | "both", state?: string) => {
337
324
  const r = renderer!
@@ -740,12 +727,8 @@ export const flushDesignComments = async (args: Args): Promise<CommentFlush | nu
740
727
  const platform = optionalPlatform(ctx)
741
728
  if (!platform) return null
742
729
 
743
- let designServer: typeof import("lecodes-design/server")
744
- try {
745
- designServer = await import("lecodes-design/server")
746
- } catch {
747
- return null
748
- }
730
+ const designServer = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design", install: false })
731
+ if (!designServer) return null
749
732
  // `settleQueued` must take `delivered` (4 params): settling a half-failed push with the older
750
733
  // signature would drop the writes that DIDN'T go through instead of retrying them.
751
734
  if (typeof designServer.queuedThreads !== "function") return null
@@ -71,6 +71,7 @@ const run = async (args: Args) => {
71
71
  minHeight: win?.minHeight,
72
72
  resizable: win?.resizable,
73
73
  title: flagStr(args, "title") ?? win?.title ?? appCfg?.name,
74
+ storageScope: appCfg?.name,
74
75
  // --fullscreen forces it on; --windowed forces it off (over app.json); else the config decides.
75
76
  fullscreen: flagBool(args, "fullscreen") ? true : flagBool(args, "windowed") ? false : win?.fullscreen,
76
77
  scale: resolveScale(flagStr(args, "scale"), win?.scale),
@@ -78,6 +79,8 @@ const run = async (args: Args) => {
78
79
  // from app.json only. Both are desktop-level (the machine/app), not window-level.
79
80
  renderScale: resolveRenderScale(flagStr(args, "render-scale"), appCfg?.desktop?.renderScale),
80
81
  maxRenderSize: appCfg?.desktop?.maxRenderSize,
82
+ mouseEmulateTouch: appCfg?.desktop?.mouseEmulateTouch,
83
+ virtualKeyboard: appCfg?.desktop?.virtualKeyboard,
81
84
  // --fps: the host's own corner counter. A flag and not app.json on purpose — it is something you
82
85
  // switch on while looking at a frame, not a property of the app.
83
86
  fps: flagBool(args, "fps"),
@@ -8,6 +8,7 @@ import { startDevServer } from "../dev/devServer"
8
8
  import { resolveWebRunnerDir } from "../dev/webRunner"
9
9
  import { openBrowser } from "../browserAuth"
10
10
  import { runDesktopDev } from "../desktopRenderer"
11
+ import { androidCrashLines, launchOnAndroid, type AndroidDevSession } from "../dev/androidDev"
11
12
  import { readAppConfigAt } from "./appShared"
12
13
  import { resolveScale } from "./desktop"
13
14
  import { CliError, c, flagBool, flagStr, log, note, toKey, warnErr, type Args } from "../util"
@@ -160,6 +161,7 @@ export const dev = async (args: Args) => {
160
161
  }
161
162
 
162
163
  const debug = process.env.LECODES_DEV_DEBUG === "1"
164
+ const underBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
163
165
  let timer: NodeJS.Timeout | null = null
164
166
  const onChange = (file: string | null) => {
165
167
  if (debug) note(`[watch] event: ${file ?? "(null)"} → ${shouldRecompile(file) ? "recompile" : "skip"}`)
@@ -210,6 +212,46 @@ export const dev = async (args: Args) => {
210
212
  openBrowser(webLocal)
211
213
  }
212
214
 
215
+ // --android: open the dev URL in the LeCodes app on the phone (adb; see dev/androidDev.ts). With
216
+ // --time <s> the run is bounded — screenshot to --png, force-stop, exit — which is the shape a
217
+ // scripted check wants; without it the app runs until Ctrl+C, and Ctrl+C force-stops it (a
218
+ // device run has no frame cap, and a 3D scene left rendering heats a charging phone).
219
+ if (flagBool(args, "android")) {
220
+ const seconds = Number(flagStr(args, "time")) || 0
221
+ const png = flagStr(args, "png")
222
+ // Under Bun the websocket channel never delivers (see the warning above): the phone would sit on
223
+ // "Connected — loading the project…" forever. The path-form poll url works on both runtimes.
224
+ const deviceUrl = `http://127.0.0.1:${port}${server.url.slice(origin.length)}`.replace(/\/dev\.js$/, underBun ? "/dev-poll.js" : "/dev.js")
225
+ let session: AndroidDevSession
226
+ try {
227
+ session = launchOnAndroid({ port, url: deviceUrl, serial: flagStr(args, "serial"), apk: flagStr(args, "apk"), packageName: flagStr(args, "package") })
228
+ } catch (e) {
229
+ server.close(); watcher?.close()
230
+ throw e
231
+ }
232
+ note(`Android: ${session.serial} → ${deviceUrl} (adb reverse; device logs stream here as [device:log])`)
233
+ const finish = (code: number) => {
234
+ session.stop()
235
+ server.close(); watcher?.close()
236
+ process.exit(code)
237
+ }
238
+ process.on("SIGINT", () => { note("Stopping the app on the phone…"); finish(0) })
239
+ if (seconds > 0) {
240
+ setTimeout(() => {
241
+ const crashes = androidCrashLines(session)
242
+ if (png) {
243
+ if (session.screenshot(png)) note(`Wrote ${png} (device screenshot after ${seconds} s)`)
244
+ else warnErr(`Screenshot failed (adb screencap).`)
245
+ }
246
+ if (crashes.length > 0) {
247
+ warnErr(`The app crashed on the phone:\n${crashes.join("\n")}`)
248
+ finish(1)
249
+ }
250
+ finish(0)
251
+ }, seconds * 1000)
252
+ }
253
+ }
254
+
213
255
  // --desktop: open the dev URL in the native host too (same server, same hot reload — the host
214
256
  // speaks the websocket channel). Window config comes from app.json's desktop block. The window
215
257
  // closing does NOT stop the server: phones stay connected, and re-running with --desktop is the
@@ -16,7 +16,7 @@ from \`main.ts\`.
16
16
 
17
17
  \`\`\`
18
18
  lecodes render # semantic JSON of the launched UI (structure, text, names, values)
19
- lecodes render --png shot.png # pixels (needs @napi-rs/canvas installed)
19
+ lecodes render --png shot.png # pixels (the renderer + @napi-rs/canvas install themselves on first use; --yes skips the prompt)
20
20
  lecodes render --png card.png --clip card # ONE component/region: a node name or x,y,w,h
21
21
  lecodes render src/screens/cart.ts # ONE screen module, without booting the app: any file that
22
22
  # \`export default\`s a UIScreen (or a (state) => UIScreen —
@@ -36,6 +36,11 @@ lecodes render --desktop --script <file> # the same scenario on the NATIVE host
36
36
  lecodes test # all tests/*.flow.json + design-derived flows; run before "done"
37
37
  lecodes test --desktop # the file flows on the NATIVE host (3D/Jolt/real GPU; coordinate
38
38
  # flows only — design flows stay headless)
39
+ lecodes dev --android --time 12 --png phone.png # the REAL phone over adb (USB): launches the app on
40
+ # the dev url via adb reverse, streams device logs as [device:log],
41
+ # screenshots after 12 s, force-stops, exit 1 on a crash. --serial
42
+ # <id> with several devices, --apk <file> to install a build first.
43
+ # Without --time it runs until Ctrl+C (which stops the app).
39
44
  \`\`\`
40
45
 
41
46
  Iterate with \`--script\` scenarios and keep the useful ones as \`tests/<name>.flow.json\` — they
@@ -90,6 +95,13 @@ channel for game state that has no UI.
90
95
  with \`"fixtures": "fixtures.json"\` in its envelope.
91
96
  - \`fill\` fills every empty input — use it before tapping a disabled-until-filled submit.
92
97
 
98
+ ## 3D scenes
99
+
100
+ - A HUD over a \`Scene\` is a widget ATTACHED to it: \`const hud = UIWidget(...).style({ left: 16,
101
+ bottom: 16, pointerEvents: "none" }); hud.attachTo(scene); hud.show()\`. Never a \`UIScreen\`:
102
+ a screen is a destination, so \`UIScreen(...).open()\` after \`scene.open()\` CLOSES the scene
103
+ (the host logs "3D scene closed" and you see only the screen) — no background makes it transparent.
104
+
93
105
  ## SDK documentation
94
106
 
95
107
  \`.lecodes/types/\` is the exact API surface (signatures only; generated and gitignored — run
@@ -8,6 +8,7 @@ import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolv
8
8
  import { runDesktopRender } from "../desktopRenderer"
9
9
  import { evaluateDesktopRun, translateScenario } from "../desktopScript"
10
10
  import { CliError, c, flagStr, flagBool, isNullSink, logErr, note, warnErr, type Args } from "../util"
11
+ import { loadPeer } from "../peerInstall"
11
12
 
12
13
  /*
13
14
  * `lecodes render` — compile the local project and render it headless. By default it emits a
@@ -81,14 +82,9 @@ export const resolveSafeAreaChoice = (explicit: string | undefined, device: stri
81
82
  return undefined
82
83
  }
83
84
 
84
- /** Import the optional renderer package or fail with the install hint. */
85
- export const requireRenderer = async (): Promise<typeof import("lecodes-renderer/headless")> => {
86
- try {
87
- return await import("lecodes-renderer/headless")
88
- } catch {
89
- throw new CliError("The renderer isn't installed. Run: npm install -g lecodes-renderer")
90
- }
91
- }
85
+ /** The optional renderer package — installed into ~/.lecodes/peers on first use (peerInstall.ts). */
86
+ export const requireRenderer = async (): Promise<typeof import("lecodes-renderer/headless")> =>
87
+ loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes render" })
92
88
 
93
89
  /** A file-writing ScenarioIO rooted at `dir` (creates parent folders as needed). */
94
90
  export const fileScenarioIO = (dir: string) => ({
@@ -5,6 +5,7 @@ import { compileSceneEditor } from "../compile/sceneCompile"
5
5
  import { localShaderArtifact } from "../compile/shaders"
6
6
  import { openBrowser } from "../browserAuth"
7
7
  import { CliError, c, flagBool, flagStr, log, note, warnErr, type Args } from "../util"
8
+ import { loadPeer } from "../peerInstall"
8
9
 
9
10
  /*
10
11
  * `lecodes scene [path]` — the 3D scene editor (lecodes-tools/packages/lecodes-3d-editor) hosted on a local project,
@@ -112,12 +113,7 @@ const countScenes = (dir: string): number => {
112
113
  export const scene = async (args: Args) => {
113
114
  const ctx = getContext(args)
114
115
 
115
- let sceneServer: typeof import("lecodes-3d-editor/server")
116
- try {
117
- sceneServer = await import("lecodes-3d-editor/server")
118
- } catch {
119
- throw new CliError("The scene editor isn't installed. Run: npm install -g lecodes-3d-editor")
120
- }
116
+ const sceneServer = await loadPeer<typeof import("lecodes-3d-editor/server")>("lecodes-3d-editor", "server", { for: "lecodes scene" })
121
117
 
122
118
  const port = Number(flagStr(args, "port")) || DEFAULT_PORT
123
119
 
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from "node:fs"
2
+ import { relative } from "node:path"
2
3
  import { findProjectRoot, readManifest, type Manifest } from "../manifest"
3
4
  import { collectLocalEntries } from "../compile/collectLocal"
4
5
  import {
@@ -8,6 +9,7 @@ import {
8
9
  import { fetchMatcManifest, installedMatcVersions, resolveMatcExe, resolveMatcExeCached, updateMatc } from "../matcTool"
9
10
  import { semverNewer } from "../releases"
10
11
  import { CliError, c, flagStr, log, note, warnErr, type Args } from "../util"
12
+ import { engineMaterialNames, writeNewShader } from "./shadersNew"
11
13
  import { parseShaderSchema, type AnyShaderPlatform } from "lecodes-sdk/compile"
12
14
 
13
15
  /*
@@ -17,6 +19,9 @@ import { parseShaderSchema, type AnyShaderPlatform } from "lecodes-sdk/compile"
17
19
  * compile compile every shader in the project into .lecodes/shaders (downloads matc on
18
20
  * first use). `--platform a,b` compiles only those targets.
19
21
  * update download the newest published matc (run after an engine upgrade)
22
+ * new <name> a fresh .mat + its .material.ts asset in the current dir (--dir elsewhere): the
23
+ * starter template, or `--ref lit|unlit|particles|…` = a copy of that engine source
24
+ * to start from (shadersNew.ts); --force overwrites
20
25
  * matc (Filament's material compiler) caches in ~/.lecodes/matc/; LECODES_MATC overrides it.
21
26
  *
22
27
  * A shader compiles to one artifact per target: `opengl` + `metal` (mobile profile, Android/Apple),
@@ -148,10 +153,21 @@ const compile = async (args: Args) => {
148
153
  note(`${shaders.length} shader${shaders.length === 1 ? "" : "s"} compiled${scope} → ${shadersCacheDir(root)}`)
149
154
  }
150
155
 
156
+ const newShader = (args: Args) => {
157
+ const name = args._[1]
158
+ if (!name) throw new CliError(`lecodes shaders new <name> [--ref ${engineMaterialNames().join("|") || "lit"}] [--dir <folder>] [--force]`)
159
+ const res = writeNewShader({ name, ref: flagStr(args, "ref"), dir: flagStr(args, "dir"), force: args.flags.force === true })
160
+ const rel = (p: string) => relative(process.cwd(), p)
161
+ log(`${rel(res.mat)} ${c.dim(res.ref ? `(from the engine's ${res.ref}.mat)` : "(the starter template)")}`)
162
+ log(`${rel(res.asset)} ${c.dim("(defineMaterial asset — a scene takes it as `material:`)")}`)
163
+ note(`next: edit it, then \`lecodes shaders compile\` (or just \`lecodes dev\`); in code: Material.load(asset('./${name.replace(/\.mat$/, "")}.mat'))`)
164
+ }
165
+
151
166
  export const shaders = async (args: Args) => {
152
167
  const sub = args._[0]
153
168
  if (sub === undefined) return status()
154
169
  if (sub === "compile") return compile(args)
155
170
  if (sub === "update") return updateMatc()
156
- throw new CliError(`Unknown subcommand "shaders ${sub}". Use \`lecodes shaders\` (status), \`lecodes shaders compile [--platform a,b]\` or \`lecodes shaders update\`.`)
171
+ if (sub === "new") return newShader(args)
172
+ throw new CliError(`Unknown subcommand "shaders ${sub}". Use \`lecodes shaders\` (status), \`lecodes shaders compile [--platform a,b]\`, \`lecodes shaders new <name> [--ref lit]\` or \`lecodes shaders update\`.`)
157
173
  }
@@ -0,0 +1,133 @@
1
+ // `lecodes shaders new <name> [--ref lit] [--dir assets/shaders] [--force]` — a fresh .mat in the
2
+ // project, ready to compile: either the starter template (an unlit material with an annotated
3
+ // parameter of each kind) or a COPY of one of the engine's own sources (`--ref lit`, `--ref unlit`,
4
+ // `--ref particles`…) renamed after the project's file — the way to get vertex colours, an emissive
5
+ // term or a second map onto an otherwise standard material without touching the engine. A
6
+ // `<name>.material.ts` asset goes next to it (defineMaterial with the parameters the .mat declares).
7
+ //
8
+ // The engine's sources come from the CLI's runtime/materials (vendored at build time from
9
+ // creator-gl/materials/src, see scripts/vendor-runtime.ts); in the monorepo the source dir itself
10
+ // is used, so a dev CLI sees the sources as they are.
11
+
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
13
+ import { basename, join, relative, resolve } from "node:path"
14
+ import { parseShaderSchema } from "lecodes-sdk/compile"
15
+ import { distRoot } from "../distRoot"
16
+ import { CliError } from "../util"
17
+
18
+ /** where the engine's .mat sources are, or null when this CLI carries none */
19
+ export const engineMaterialsDir = (): string | null => {
20
+ const candidates = [
21
+ join(distRoot, "runtime", "materials"),
22
+ join(distRoot, "..", "creator-gl", "materials", "src"),
23
+ ]
24
+ for (const c of candidates) if (existsSync(join(c, "unlit.mat"))) return c
25
+ return null
26
+ }
27
+
28
+ /** the sources a `--ref` may name (the common set every host ships; the AR camera ones are not materials to start from) */
29
+ export const engineMaterialNames = (): string[] => {
30
+ const dir = engineMaterialsDir()
31
+ if (!dir) return []
32
+ return readdirSync(dir).filter((f) => f.endsWith(".mat") && !f.startsWith("camera") && f !== "shader360.mat").map((f) => f.slice(0, -4)).sort()
33
+ }
34
+
35
+ const NAME_RE = /^[a-z][a-z0-9_-]*$/i
36
+ /** the `name :` inside the .mat — an identifier (Filament's rule), so a dash in the file name becomes an underscore */
37
+ const matName = (name: string): string => name.replace(/-/g, "_")
38
+
39
+ /** the starter: unlit, one parameter of each kind with the editor annotations spelled out */
40
+ export const starterMat = (name: string): string => `// ${name}.mat — a custom material (lecodes shaders new).
41
+ //
42
+ // Filament's material language: the \`material\` block declares the parameters and the render state,
43
+ // \`fragment\` computes the surface. Read it with \`Material.load(asset('./${name}.mat'))\` or the
44
+ // ${name}.material.ts asset beside it; set parameters with material.set('tint', '#ff8800').
45
+ // \`lecodes shaders compile\` (or \`lecodes dev\`) compiles it for every host into .lecodes/shaders.
46
+ //
47
+ // The comment on a parameter's line is what the scene editor shows for it:
48
+ // @color a colour picker (also inferred from a name like tint / color / emissive)
49
+ // @range(min, max) a slider, @step(s) its step, @default(v) the value a new material starts with
50
+ // the rest of the line is the tooltip
51
+
52
+ material {
53
+ name : ${matName(name)},
54
+ shadingModel : unlit,
55
+ parameters : [
56
+ { type : float3, name : tint }, // @color @default(#ffffff) the flat colour
57
+ { type : float, name : glow }, // @range(0, 2) @step(0.05) @default(1) brightness multiplier
58
+ { type : sampler2d, name : map } // the texture, white when unset
59
+ ],
60
+ requires : [
61
+ uv0
62
+ ]
63
+ }
64
+
65
+ fragment {
66
+ void material(inout MaterialInputs material) {
67
+ prepareMaterial(material);
68
+ vec4 base = texture(materialParams_map, getUV0());
69
+ material.baseColor.rgb = base.rgb * materialParams.tint * materialParams.glow;
70
+ }
71
+ }
72
+ `
73
+
74
+ /** the engine's source renamed after the file, with a header saying where it came from */
75
+ export const refMat = (name: string, ref: string, source: string): string => {
76
+ const renamed = source.replace(/(\bname\s*:\s*)([A-Za-z_][A-Za-z0-9_]*)/, `$1${matName(name)}`)
77
+ return `// ${name}.mat — started from the engine's ${ref}.mat (lecodes shaders new --ref ${ref}); edit freely,
78
+ // the engine keeps its own. \`lecodes shaders compile\` builds it for every host into .lecodes/shaders.
79
+ // Load it with Material.load(asset('./${name}.mat')) or the ${name}.material.ts asset beside it.
80
+
81
+ ${renamed}`
82
+ }
83
+
84
+ /** the defineMaterial asset for a .mat: every non-sampler parameter with the value the schema gives it */
85
+ export const materialAsset = (name: string, mat: string): string => {
86
+ const schema = parseShaderSchema(mat)
87
+ const lines: string[] = []
88
+ for (const p of schema.params) {
89
+ if (p.editor === "texture") { lines.push(` // ${p.name}: asset('./texture.png'), // a sampler takes an image asset`); continue }
90
+ if (p.default === null || p.default === undefined) continue
91
+ lines.push(` ${p.name}: ${JSON.stringify(p.default)},${p.tooltip ? ` // ${p.tooltip}` : ""}`)
92
+ }
93
+ return `// ${name}.material.ts — the material as a project asset: ONE shared instance a scene file takes as
94
+ // \`material: ${camel(name)}\` and code loads with \`await ${camel(name)}.load()\`. The parameters and their
95
+ // widgets come from the annotations in ${name}.mat.
96
+ export default defineMaterial({
97
+ shader: asset('./${name}.mat'),
98
+ params: {
99
+ ${lines.join("\n")}
100
+ },
101
+ })
102
+ `
103
+ }
104
+ const camel = (name: string): string => name.replace(/[-_]+(.)/g, (_, ch: string) => ch.toUpperCase())
105
+
106
+ export type NewShaderOptions = { name: string, ref?: string, dir?: string, force?: boolean, cwd?: string }
107
+ export type NewShaderResult = { mat: string, asset: string, ref: string | null }
108
+
109
+ /** Write `<name>.mat` and `<name>.material.ts`; returns the paths. Throws a CliError on a bad name,
110
+ * an unknown --ref, or files already there (unless `force`). */
111
+ export const writeNewShader = (o: NewShaderOptions): NewShaderResult => {
112
+ const name = o.name.endsWith(".mat") ? o.name.slice(0, -4) : o.name
113
+ if (!NAME_RE.test(name)) throw new CliError(`"${o.name}" is not a shader name — letters, digits, - and _ (starting with a letter), e.g. hologram or rim-light`)
114
+ const dir = resolve(o.cwd ?? process.cwd(), o.dir ?? ".")
115
+ const matPath = join(dir, `${name}.mat`), assetPath = join(dir, `${name}.material.ts`)
116
+ if (!o.force) for (const p of [ matPath, assetPath ]) if (existsSync(p)) throw new CliError(`${relative(o.cwd ?? process.cwd(), p) || basename(p)} exists — pick another name or pass --force to overwrite`)
117
+
118
+ let mat: string
119
+ let ref: string | null = null
120
+ if (o.ref) {
121
+ ref = o.ref.endsWith(".mat") ? o.ref.slice(0, -4) : o.ref
122
+ const srcDir = engineMaterialsDir()
123
+ if (!srcDir) throw new CliError("this CLI carries no engine material sources (runtime/materials) — --ref needs a CLI built with them")
124
+ const known = engineMaterialNames()
125
+ if (!known.includes(ref)) throw new CliError(`no engine material "${ref}". Known: ${known.join(", ")}`)
126
+ mat = refMat(name, ref, readFileSync(join(srcDir, `${ref}.mat`), "utf8"))
127
+ } else mat = starterMat(name)
128
+
129
+ mkdirSync(dir, { recursive: true })
130
+ writeFileSync(matPath, mat)
131
+ writeFileSync(assetPath, materialAsset(name, mat))
132
+ return { mat: matPath, asset: assetPath, ref }
133
+ }
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process"
3
3
  import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"
4
4
  import { tmpdir } from "node:os"
5
5
  import { extname, join, relative, resolve } from "node:path"
6
+ import { loadPeer } from "../peerInstall"
6
7
  import { projectRootOf } from "./scene"
7
8
  import { CliError, c, endProgress, flagBool, flagStr, log, note, progress, toKey, warnErr, type Args } from "../util"
8
9
 
@@ -102,12 +103,8 @@ const collect = (dir: string): string[] => {
102
103
  export const thumbs = async (args: Args) => {
103
104
  const root = projectRootOf(process.cwd())
104
105
 
105
- let sceneServer: typeof import("lecodes-3d-editor/server")
106
- try {
107
- sceneServer = await import("lecodes-3d-editor/server")
108
- } catch {
109
- throw new CliError("The scene editor isn't installed (it owns the preview cache). Run: npm install -g lecodes-3d-editor")
110
- }
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" })
111
108
 
112
109
  const size = Number(flagStr(args, "size")) || DEFAULT_SIZE
113
110
  const force = flagBool(args, "force")