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
@@ -16,14 +16,14 @@ 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 —
23
23
  # --state <name> picks the branch). Same flags as above.
24
24
  lecodes render --desktop --png shot.png # NATIVE render: real Filament 3D + UI + 2D composited —
25
25
  # use this to SEE 3D scenes / procedural geometry (headless
26
- # renders record 3D as JSON only). Windows/Linux; downloads
26
+ # renders record 3D as JSON — simulated, not drawn). Windows/Linux; downloads
27
27
  # the renderer on first use. --frames <n> for slow loads.
28
28
  lecodes render --script <file> # drive the app through a scenario (format below)
29
29
  lecodes render --desktop --view iso --png iso.png # 3D: inspection camera fitted to the WHOLE
@@ -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
@@ -72,8 +77,9 @@ advance in fixed 1/60 steps driven by \`wait\`/\`frames\`/\`drag\`/\`key\` — n
72
77
  so a flow that passes once passes every time. \`Input.key(code)\` reads the keys a flow holds and
73
78
  \`Input.on('keydown' | 'keyup', e => e.code)\` fires for each \`key\`/\`keyDown\`/\`keyUp\` step
74
79
  (events land before the next loop tick); \`stick\` values are what \`Input.gamepad(0).axis()\` reads.
75
- Headless draws UI + the 2D scene (sprites, tilemaps, Box2D); 3D is captured as JSON and Jolt does
76
- not simulate — use \`--desktop\` for 3D pixels. \`console.log\` + \`expect.log\` is the assertion
80
+ Headless draws UI + the 2D scene (sprites, tilemaps, Box2D); 3D is captured as JSON with REAL Jolt
81
+ physics (bodies fall, characters collide, triggers fire, ragdolls drop — a character with no ground
82
+ under it falls, like on device) — use \`--desktop\` for 3D pixels. \`console.log\` + \`expect.log\` is the assertion
77
83
  channel for game state that has no UI.
78
84
 
79
85
  ## Make the app testable
@@ -89,6 +95,13 @@ channel for game state that has no UI.
89
95
  with \`"fixtures": "fixtures.json"\` in its envelope.
90
96
  - \`fill\` fills every empty input — use it before tapping a disabled-until-filled submit.
91
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
+
92
105
  ## SDK documentation
93
106
 
94
107
  \`.lecodes/types/\` is the exact API surface (signatures only; generated and gitignored — run
@@ -4,10 +4,11 @@ import { compileHeadlessBundle } from "../compile/headlessBundle"
4
4
  import { screenTargetFromArgs } from "../compile/screenEntry"
5
5
  import { findProjectRoot } from "../manifest"
6
6
  import { buildBundle } from "./compile"
7
- import { desktopDefaultViewport, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
7
+ import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
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) => ({
@@ -196,6 +192,8 @@ const renderDesktop = async (args: Args) => {
196
192
 
197
193
  // One renderer decision: the host binary AND the staged .filamat variant (see appShared).
198
194
  const desktopRenderer = resolveDesktopRenderer(root, args)
195
+ // Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
196
+ const { dirs: pluginDirs } = desktopPluginLibraries(root)
199
197
  const { js, resources } = await buildBundle(args, undefined, {
200
198
  localAssets: true, desktopRenderer,
201
199
  extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
@@ -212,7 +210,7 @@ const renderDesktop = async (args: Args) => {
212
210
  const timeoutMs = Number(flagStr(args, "timeout")) || (60000 + plan.steps.length * 5000)
213
211
  note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (native)…`)
214
212
  const { output } = await runDesktopRender(js, resources, {
215
- renderer: desktopRenderer,
213
+ renderer: desktopRenderer, pluginDirs,
216
214
  width, height, frames: plan.frameCap, outPng: pngFlag, logs, timeoutMs, script: plan.text, fixedDtMs,
217
215
  })
218
216
  const result = evaluateDesktopRun(plan, output)
@@ -234,7 +232,7 @@ const renderDesktop = async (args: Args) => {
234
232
  const timeoutMs = Number(flagStr(args, "timeout")) || 60000
235
233
  note(screen ? `Rendering ${screen.label} (native)…` : "Rendering (native)…")
236
234
  await runDesktopRender(js, resources, {
237
- renderer: desktopRenderer,
235
+ renderer: desktopRenderer, pluginDirs,
238
236
  width, height, frames, outPng: pngFlag, logs, timeoutMs, fixedDtMs, camera, view,
239
237
  })
240
238
  note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"
2
2
  import { dirname, resolve } from "node:path"
3
3
  import { compileHeadlessBundle } from "../compile/headlessBundle"
4
- import { desktopDefaultViewport, resolveDesktopRenderer } from "./appShared"
4
+ import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer } from "./appShared"
5
5
  import { fileScenarioIO, requireRenderer, resolveFixtures, resolveSafeAreaChoice } from "./render"
6
6
  import { buildBundle } from "./compile"
7
7
  import { findProjectRoot } from "../manifest"
@@ -261,6 +261,7 @@ export const test = async (args: Args) => {
261
261
  const finalPng = resolve(caseDir, "final.png")
262
262
  const { output } = await runDesktopRender(native!.js, native!.resources, {
263
263
  renderer: desktopRenderer,
264
+ pluginDirs: desktopPluginLibraries(root).dirs,
264
265
  width: Number(flagStr(args, "width")) || kase.width || projectViewport?.width || 390,
265
266
  height: Number(flagStr(args, "height")) || kase.height || projectViewport?.height || 844,
266
267
  frames: plan.frameCap, outPng: finalPng, logs: flagBool(args, "logs"),
@@ -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")
@@ -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.
@@ -1,7 +1,7 @@
1
1
  import { execFileSync, spawn, spawnSync } from "node:child_process"
2
2
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
3
3
  import { homedir, tmpdir } from "node:os"
4
- import { join, resolve } from "node:path"
4
+ import { delimiter, join, resolve } from "node:path"
5
5
  import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
6
6
  import { CliError, note, warnErr } from "./util"
7
7
  import { makeStackDemangler } from "./compile/nativeStack"
@@ -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,22 +297,39 @@ 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
299
309
  /** Multiplayer launch facts for the host (`--server --port N --max-clients N` / `--connect ip:port`);
300
310
  * the SDK reads them through `Net.launch` and decides (docs/multiplayer-plan.md). */
301
311
  net?: { role: "server" | "client"; address?: string; port?: number; maxClients?: number }
312
+ /** Dirs the host scans for native plugin libraries (CREATOR_PLUGIN_DIRS; the loader was added
313
+ * after host 1.4.0 — an older host ignores the env). See appShared.desktopPluginLibraries. */
314
+ pluginDirs?: string[]
315
+ }
316
+
317
+ /** CREATOR_PLUGIN_DIRS: the project's plugin dirs first, then whatever the environment already
318
+ * carried (a `.env` CREATOR_PLUGIN_DIRS keeps working alongside the project's `plugins/`). */
319
+ const pluginEnv = (dirs: string[] | undefined): Record<string, string> => {
320
+ const all = [...(dirs ?? []), ...(process.env.CREATOR_PLUGIN_DIRS ? [process.env.CREATOR_PLUGIN_DIRS] : [])]
321
+ return all.length > 0 ? { CREATOR_PLUGIN_DIRS: all.join(delimiter) } : {}
302
322
  }
303
323
 
304
324
  const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
305
- const env: Record<string, string> = {}
325
+ const env: Record<string, string> = { ...pluginEnv(opts.pluginDirs) }
306
326
  // A lone width/height fills the other side from the host default so it isn't silently ignored.
307
327
  const width = opts.width ?? (opts.height !== undefined ? 1280 : undefined)
308
328
  const height = opts.height ?? (opts.width !== undefined ? 720 : undefined)
309
329
  if (width && height) env.CREATOR_WINDOW_SIZE = `${width}x${height}`
310
330
  if (opts.minWidth && opts.minHeight) env.CREATOR_MIN_WINDOW_SIZE = `${opts.minWidth}x${opts.minHeight}`
311
331
  if (opts.title) env.CREATOR_WINDOW_TITLE = opts.title
332
+ if (opts.storageScope) env.CREATOR_STORAGE_SCOPE = opts.storageScope
312
333
  if (opts.resizable === false) env.CREATOR_WINDOW_FIXED = "1"
313
334
  if (opts.fullscreen !== undefined) env.CREATOR_WINDOW_FULLSCREEN = opts.fullscreen ? "1" : "0"
314
335
  if (opts.scale === "fit") env.CREATOR_UI_SCALE = "fit"
@@ -318,6 +339,12 @@ const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
318
339
  env.CREATOR_RENDER_MAX = `${Math.round(opts.maxRenderSize[0])}x${Math.round(opts.maxRenderSize[1])}`
319
340
  }
320
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
+ }
321
348
  return env
322
349
  }
323
350
 
@@ -356,6 +383,8 @@ export type DesktopRenderOptions = {
356
383
  /** Inspection camera: CREATOR_CAMERA ("ex,ey,ez;tx,ty,tz[;fov]") or CREATOR_VIEW ("iso[;fov]"). */
357
384
  camera?: string
358
385
  view?: string
386
+ /** Dirs the host scans for native plugin libraries (CREATOR_PLUGIN_DIRS). */
387
+ pluginDirs?: string[]
359
388
  }
360
389
 
361
390
  export type DesktopRenderOutcome = {
@@ -482,6 +511,7 @@ export const runDesktopRender = async (
482
511
  if (opts.fixedDtMs) env.CREATOR_FIXED_DT = String(opts.fixedDtMs)
483
512
  if (opts.camera) env.CREATOR_CAMERA = opts.camera
484
513
  if (opts.view) env.CREATOR_VIEW = opts.view
514
+ Object.assign(env, pluginEnv(opts.pluginDirs))
485
515
  // Frames the host prints name the minified bundle (`module.js:5:51631`); map them back to
486
516
  // project positions. Chunks stay raw so `[script]` marker parsing sees exactly what was sent —
487
517
  // the rewrite happens on the joined text (and best-effort per chunk for live --logs).
@@ -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