lecodes-cli 2.0.4 → 2.0.6

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 (74) hide show
  1. package/dist/index.js +3506 -1819
  2. package/package.json +20 -11
  3. package/runtime/sdk-types.json +1 -1
  4. package/runtime/web/VERSION.json +3 -3
  5. package/runtime/web/assets/creator-2d-CYlV77UR.js +1 -0
  6. package/runtime/web/assets/creator-2d-nG7rGEmq.wasm +0 -0
  7. package/runtime/web/assets/{creator-full-D2dSDdDj.wasm → creator-full-Cla1R8N6.wasm} +0 -0
  8. package/runtime/web/assets/creator-full-DmfoULcB.js +1 -0
  9. package/runtime/web/assets/creator-full.js-D8SMHuCt.symbols +8419 -0
  10. package/runtime/web/assets/creator-ui-CbtoaRlz.js +1 -0
  11. package/runtime/web/assets/creator-ui-DZaoxIpX.wasm +0 -0
  12. package/runtime/web/player.js +18 -18
  13. package/runtime/web-lite/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  14. package/runtime/web-lite/assets/ar-BOdQ-wV4.js +1 -0
  15. package/runtime/web-lite/assets/basis-C64VHDVD.js +1 -0
  16. package/runtime/web-lite/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  17. package/runtime/web-lite/assets/{canvas-C6G_Pb2Q.js → canvas-WxiusXCB.js} +1 -1
  18. package/runtime/web-lite/assets/creatorAnim-BOwKr-C1.js +1 -0
  19. package/runtime/web-lite/assets/creatorParticles-DYlv4g1J.js +1 -0
  20. package/runtime/web-lite/assets/creatorPhysics-k7wL0kV1.js +1 -0
  21. package/runtime/web-lite/assets/draco-BiISTFcR.js +118 -0
  22. package/runtime/web-lite/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  23. package/runtime/web-lite/assets/index-3CbsYWmo.js +2 -0
  24. package/runtime/web-lite/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  25. package/runtime/web-lite/assets/{pager-O712dZv9.js → pager-Db5BY7FB.js} +1 -1
  26. package/runtime/web-lite/assets/plugins-CuEPHbHL.js +1 -0
  27. package/runtime/web-lite/assets/scene3d-CX7MAqmN.js +866 -0
  28. package/runtime/web-lite/assets/tween-BnWYhJ7P.js +1 -0
  29. package/runtime/web-lite/assets/video-BLj61nDx.js +1 -0
  30. package/runtime/web-lite/assets/{vlist-gEB8Sn0S.js → vlist-DrV2Q7P5.js} +1 -1
  31. package/runtime/web-lite/assets/worker-Caf-yYEI.js +2 -0
  32. package/runtime/web-lite/features.json +87 -4
  33. package/runtime/web-lite/lite.js +39 -10
  34. package/runtime/web-lite/plugins/camera/index-5XOM8eHl.js +1 -0
  35. package/runtime/web-lite/plugins/geolocation/index-BjTKN9_P.js +1 -0
  36. package/runtime/web-lite/plugins/map/_virtual_lecodes-half-CQBO044A.js +1 -0
  37. package/runtime/web-lite/plugins/map/_virtual_lecodes-half-resources-BYwqQkWQ.js +1 -0
  38. package/runtime/web-lite/plugins/map/assets/maplibre-gl-worker-DeQFNGa4.js +8 -0
  39. package/runtime/web-lite/plugins/map/index-BTBHQ264.js +1 -0
  40. package/runtime/web-lite/plugins/map/mapImpl-C6E1SwCS.js +823 -0
  41. package/runtime/web-lite/plugins/qr-scanner/index-CK_cYoJR.js +1 -0
  42. package/runtime/web-lite/plugins.json +8 -0
  43. package/src/commands/compile.ts +7 -1
  44. package/src/commands/desktop.ts +7 -4
  45. package/src/commands/dev.ts +4 -14
  46. package/src/commands/render.ts +6 -2
  47. package/src/commands/test.ts +25 -9
  48. package/src/commands/update/index.ts +12 -7
  49. package/src/commands/update/types.ts +1 -1
  50. package/src/compile/projectCompile.ts +48 -1
  51. package/src/dev/androidDev.ts +1 -1
  52. package/src/dev/backendWorker.ts +138 -0
  53. package/src/dev/devServer.ts +119 -115
  54. package/src/dev/localBackend.ts +214 -0
  55. package/src/dev/projectBackend.ts +199 -0
  56. package/src/dev/serve.ts +164 -0
  57. package/src/hosts/cmgenTool.ts +6 -9
  58. package/src/hosts/desktopRenderer.ts +6 -5
  59. package/src/hosts/liteRunner.ts +52 -9
  60. package/src/hosts/matcTool.ts +5 -22
  61. package/src/hosts/packageManager.ts +45 -0
  62. package/src/hosts/peerInstall.ts +8 -13
  63. package/src/hosts/peers.ts +2 -0
  64. package/src/hosts/releases.ts +6 -0
  65. package/src/index.ts +5 -1
  66. package/src/plugin/generate.ts +10 -6
  67. package/src/web/build.ts +20 -3
  68. package/runtime/web/assets/creator-2d-D9WKYM6h.js +0 -1
  69. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  70. package/runtime/web/assets/creator-full-BBXzQlhq.js +0 -1
  71. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +0 -8124
  72. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  73. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +0 -1
  74. package/runtime/web-lite/assets/tween-t_XDy3Nd.js +0 -1
@@ -0,0 +1 @@
1
+ const h={present:e=>e!=null,f64:e=>typeof e=="number"&&Number.isFinite(e)?e:void 0,i32:e=>typeof e=="number"&&Number.isInteger(e)&&e>=-2147483648&&e<=2147483647?e:void 0,bool:e=>typeof e=="boolean"?e:void 0,string:e=>typeof e=="string"?e:void 0,json:e=>e,bytes:e=>Object.prototype.toString.call(e)==="[object Uint8Array]"?e.slice():void 0,object:e=>typeof e=="object"&&e!==null&&!Array.isArray(e)?e:void 0,array:(e,r)=>Array.isArray(e)&&(r===void 0||e.length===r)?e:void 0,list:(e,r)=>{if(!Array.isArray(e))return;const n=[];for(const c of e){const o=r(c);if(o===void 0)return;n.push(o)}return n},map:(e,r)=>{if(typeof e!="object"||e===null||Array.isArray(e))return;const n={};for(const[c,o]of Object.entries(e)){const s=r(o);if(s===void 0)return;n[c]=s}return n}},j=e=>!h.present(e)||h.object(e)?{}:void 0,v={name:"qrScanner",version:1},A=(e,r)=>{e.registerView(v.name,(n,c,o)=>{const s={scan:i=>c.emit("scan",i)},a=r(j(n)??{},s,o),t=async(i,p)=>{throw new Error(`Unknown method: ${i}`)};return{el:a.el,call:t,destroy:()=>{var i;return(i=a.destroy)==null?void 0:i.call(a)}}},v.version)},S=150,k=10,T=e=>{var n;const r=globalThis.BarcodeDetector;r&&(typeof navigator>"u"||typeof((n=navigator.mediaDevices)==null?void 0:n.getUserMedia)!="function"||A(e,(c,o,s)=>{const a=s.createElement("div");a.style.cssText="background:#000;overflow:hidden";const t=s.createElement("video");t.autoplay=!0,t.muted=!0,t.playsInline=!0,t.setAttribute("playsinline",""),t.style.cssText="position:absolute;inset:0;width:100%;height:100%;object-fit:cover",a.appendChild(t);const i=new r({formats:["qr_code"]}),p=s.defaultView??globalThis;let u=null,b=null,f=!1,m=!1,y=null,g=0;const w=async()=>{var l;if(!(m||f||t.videoWidth===0)){m=!0;try{const d=((l=(await i.detect(t))[0])==null?void 0:l.rawValue)??null;if(f)return;d!==null?(g=0,d!==y&&(y=d,o.scan({data:d}))):y!==null&&++g>=k&&(y=null,o.scan({data:null}))}catch{}finally{m=!1}}};return navigator.mediaDevices.getUserMedia({video:{facingMode:"environment"},audio:!1}).then(l=>{if(f){for(const d of l.getTracks())d.stop();return}u=l,t.srcObject=l,t.play().catch(()=>{}),b=p.setInterval(()=>{w()},S)},()=>{}),{el:a,destroy(){if(f=!0,b!==null&&p.clearInterval(b),u)for(const l of u.getTracks())l.stop();u=null,t.srcObject=null}}}))};export{T as register};
@@ -0,0 +1,8 @@
1
+ {
2
+ "plugins": {
3
+ "camera": "plugins/camera/index-5XOM8eHl.js",
4
+ "geolocation": "plugins/geolocation/index-BjTKN9_P.js",
5
+ "map": "plugins/map/index-BTBHQ264.js",
6
+ "qr-scanner": "plugins/qr-scanner/index-CK_cYoJR.js"
7
+ }
8
+ }
@@ -13,6 +13,7 @@ import { buildGlobMatcher } from "../project/ignore"
13
13
  import { readAppConfigAt } from "./app/shared"
14
14
  import { defineCommand, isNullSink, note, warnErr } from "../cli"
15
15
  import { bundleFlags, bundleOptions, outFlag, session, type BundleOptions } from "./shared"
16
+ import type { RunBackend } from "../dev/projectBackend"
16
17
 
17
18
  /*
18
19
  * `lecodes compile` — the local project as one JS bundle (the server's pipeline), printed to stdout
@@ -81,6 +82,10 @@ export type BuildBundleOptions = {
81
82
  entryOverride?: string
82
83
  /** Shell mode (`lecodes app sync`) — see above. */
83
84
  shell?: ShellCompile
85
+ /** The bundle is about to RUN on this machine: a project with server files gets its backend
86
+ * started here (dev/projectBackend.ts) and that backend's url in its stubs. Without it the stubs
87
+ * of a `*.server.ts` have no server to call. */
88
+ backend?: RunBackend
84
89
  }
85
90
 
86
91
  export default defineCommand({
@@ -213,8 +218,9 @@ export const buildBundle = async (bundle: BundleOptions, opts: BuildBundleOption
213
218
  const ambiguous = ambiguousEntryWarning(entries)
214
219
  if (ambiguous) warnErr(ambiguous)
215
220
  }
221
+ const serverUrl = await opts.backend?.start(entries, (w) => warnings.push(w))
216
222
  const js = await compileProject({
217
- entries, name, publicUrl, entryOverride,
223
+ entries, name, publicUrl, entryOverride, serverUrl,
218
224
  header: bundle.header ?? true,
219
225
  localAssets, fontsHeader, iconsHeader,
220
226
  minify: bundle.minify,
@@ -1,5 +1,5 @@
1
1
  import { basename } from "node:path"
2
- import { CliError, bool, c, defineCommand, log, note, num, str } from "../cli"
2
+ import { CliError, bool, c, defineCommand, log, logErr, note, num, str } from "../cli"
3
3
  import {
4
4
  DESKTOP_RENDERER_VERSION, fetchDesktopManifest, findMacHostExe, installedDesktopVersions, runDesktopApp,
5
5
  selectedDesktopVersion, semverNewer, updateDesktop,
@@ -7,6 +7,7 @@ import {
7
7
  import { desktopBuild } from "./app/desktop"
8
8
  import { desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
9
9
  import { buildBundle } from "./compile"
10
+ import { runBackend } from "../dev/projectBackend"
10
11
  import { bundleFlags, bundleOptions, localProject } from "./shared"
11
12
 
12
13
  /*
@@ -40,7 +41,7 @@ export const resolveRenderScale = (flag: string | undefined, cfg: number | undef
40
41
  const run = defineCommand({
41
42
  name: "run",
42
43
  summary: "Compile the current project and open it in a desktop window",
43
- description: "Window size / min size / title default from app.json's desktop block; asset() resources load from the working tree. Multiplayer: --server starts the host as a dedicated server, --connect joins one — both only set Net.launch (the app decides).",
44
+ description: "Window size / min size / title default from app.json's desktop block; asset() resources load from the working tree. A project with *.server.ts files gets its backend run on this machine for as long as the window is open (the database of `lecodes dev`: .lecodes/db). Multiplayer: --server starts the host as a dedicated server, --connect joins one — both only set Net.launch (the app decides).",
44
45
  flags: {
45
46
  width: num("window width (logical px)"),
46
47
  height: num("window height (logical px)"),
@@ -68,7 +69,9 @@ const run = defineCommand({
68
69
  // ONE renderer decision per run: it picks the host binary AND which .filamat gets staged beside
69
70
  // the bundle. Reading the config twice is how a Vulkan exe ends up next to an OpenGL shader.
70
71
  const renderer = resolveDesktopRenderer(root, flags.renderer)
71
- const { js, resources } = await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer: renderer })
72
+ // The project's backend (its *.server.ts files), when it has one: run here while the window is open.
73
+ const backend = runBackend({ root, command: "lecodes desktop run", log: logErr })
74
+ const { js, resources } = await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer: renderer, backend })
72
75
  // Native plugins: the project's plugins/ dir + declared plugin libraries, scanned by the host.
73
76
  const { dirs: pluginDirs, files: pluginFiles } = desktopPluginLibraries(root)
74
77
  if (pluginFiles.length > 0) note(`Native plugins: ${pluginFiles.map((f) => basename(f)).join(", ")}`)
@@ -101,7 +104,7 @@ const run = defineCommand({
101
104
  net: flags.server
102
105
  ? { role: "server", port: flags.port, maxClients: flags["max-clients"] }
103
106
  : flags.connect ? { role: "client", address: flags.connect } : undefined,
104
- })
107
+ }).finally(() => backend.close())
105
108
  },
106
109
  })
107
110
 
@@ -22,7 +22,8 @@ import { bundleFlags } from "./shared"
22
22
  * working tree. The device opens the QR URL like any project URL; every save recompiles and
23
23
  * hot-swaps the running app (`_creatorApp.run` — fresh context, rollback on error). Device
24
24
  * console output and errors stream back to this terminal. Fully local: no login, no le.codes
25
- * backend. Custom shaders compile locally too (matc, downloaded on first use — matcTool.ts);
25
+ * backend — a project's own backend (its `*.server.ts` files) runs here too, its database in
26
+ * .lecodes/db (dev/localBackend.ts). Custom shaders compile locally too (matc, downloaded on first use — matcTool.ts);
26
27
  * only when matc can't be fetched do they fall back to server-compiled artifacts (push once).
27
28
  *
28
29
  * `--web` opens the same project in the browser: the dev server also serves, under /web/, the
@@ -145,16 +146,6 @@ export default defineCommand({
145
146
  }
146
147
  const name = manifest?.name ?? basename(root)
147
148
 
148
- // Bun's node:http can't complete a websocket upgrade (writes to the upgraded socket are dropped),
149
- // so the reload push would silently never reach devices: a phone that scanned a websocket url
150
- // sat on "Connected — loading the project…" forever. Under Bun every device url — the QR, the
151
- // printed one, what --ios / --android open — is the POLL form (/dev-poll.js: a path, since the
152
- // launcher only fetches urls that end in .js). Node is the shipped runtime.
153
- const underBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
154
- if (underBun) {
155
- warnErr("Running under Bun: no websocket reload channel here — devices poll instead (the QR points at /dev-poll.js). Node is the shipped runtime.")
156
- }
157
-
158
149
  const port = flags.port || DEFAULT_PORT
159
150
  const candidates = lanCandidates()
160
151
  const host = flags.host ?? candidates[0]?.address
@@ -203,15 +194,14 @@ export default defineCommand({
203
194
  log("")
204
195
  log(`${c.bold("LeCodes Dev")} — ${name}`)
205
196
  log("")
206
- // The url a device opens: the websocket form, or the poll form under Bun (see above).
207
- const deviceUrl = underBun ? server.url.replace(/\/dev\.js$/, "/dev-poll.js") : server.url
197
+ const deviceUrl = server.url
208
198
  if (!flags["no-qr"]) await printQr(deviceUrl)
209
199
  log(`Scan the QR with the LeCodes app, or open: ${c.bold(deviceUrl)}`)
210
200
  // The browser runner: localhost is a secure context (camera works there), the LAN address is
211
201
  // what a phone's browser can reach.
212
202
  const webLocal = server.webUrl ? server.webUrl.replace(`http://${host}:`, "http://localhost:") : null
213
203
  if (server.webUrl && webLocal) log(`In the browser: ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
214
- if (!underBun) note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
204
+ note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
215
205
  if (alternatives.length > 0) {
216
206
  note(`Device can't reach it? This machine also has ${alternatives.map((a) => `${a.address} (${a.iface})`).join(", ")} — retry with --host <ip>.`)
217
207
  }
@@ -4,6 +4,7 @@ import { compileHeadlessBundle } from "../compile/headlessBundle"
4
4
  import { screenTargetFrom } from "../compile/screenEntry"
5
5
  import { findProjectRoot } from "../project/manifest"
6
6
  import { buildBundle } from "./compile"
7
+ import { runBackend } from "../dev/projectBackend"
7
8
  import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
8
9
  import { runDesktopRender } from "../hosts/desktopRenderer"
9
10
  import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
@@ -285,8 +286,10 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
285
286
  const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
286
287
  // Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
287
288
  const { dirs: pluginDirs } = desktopPluginLibraries(root)
289
+ // The project's backend, when it has one (the database of `lecodes dev`).
290
+ const backend = runBackend({ root, command: "lecodes render", log: (msg) => { if (logs || msg.includes("[server:error]")) logErr(msg) } })
288
291
  const { js, resources } = await buildBundle(bundleOptions(flags), {
289
- localAssets: true, desktopRenderer,
292
+ localAssets: true, desktopRenderer, backend,
290
293
  extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
291
294
  })
292
295
  // The host writes PNGs but never creates folders — make every output dir up front.
@@ -305,6 +308,7 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
305
308
  renderer: desktopRenderer, host: sim ? "server" : "desktop", pluginDirs,
306
309
  width, height, frames: plan.frameCap, outPng: pngFlag, logs, timeoutMs, script: plan.text, fixedDtMs,
307
310
  })
311
+ await backend.close()
308
312
  const result = evaluateDesktopRun(plan, output)
309
313
  if (!result.loaded) {
310
314
  throw new CliError("This desktop host doesn't support scripted input (CREATOR_SCRIPT) — it ran the frames and ignored the steps. Update it: lecodes desktop update (or point LECODES_DESKTOP_EXE at a newer build).")
@@ -326,7 +330,7 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
326
330
  await runDesktopRender(js, resources, {
327
331
  renderer: desktopRenderer, pluginDirs,
328
332
  width, height, frames, outPng: pngFlag, logs, timeoutMs, fixedDtMs, camera, view,
329
- })
333
+ }).finally(() => backend.close())
330
334
  note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
331
335
  }
332
336
 
@@ -4,11 +4,12 @@ import { compileHeadlessBundle } from "../compile/headlessBundle"
4
4
  import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer } from "./app/shared"
5
5
  import { desktopRenderFlags, fileScenarioIO, requireRenderer, resolveFixtures, resolveSafeAreaChoice, viewportFlags } from "./render"
6
6
  import { buildBundle } from "./compile"
7
+ import { runBackend } from "../dev/projectBackend"
7
8
  import { findProjectRoot } from "../project/manifest"
8
9
  import { runDesktopRender } from "../hosts/desktopRenderer"
9
10
  import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
10
11
  import { startLiteSession, type LiteSession } from "../hosts/liteRunner"
11
- import { resolveLiteRunner } from "../dev/webRunner"
12
+ import { resolveLiteRunner, resolveWebRunner } from "../dev/webRunner"
12
13
  import { readManifest, type Manifest } from "../project/manifest"
13
14
  import { CliError, bool, c, defineCommand, log, logErr, note, num, str, warnErr, type FlagValues } from "../cli"
14
15
  import { bundleFlags, bundleOptions } from "./shared"
@@ -157,6 +158,7 @@ const testFlags = {
157
158
  ...bundleFlags,
158
159
  ...desktopRenderFlags,
159
160
  lite: bool("run the file scenarios on Web Lite, in a browser (needs Playwright): the same steps, targets and expects; the clock is the browser's, so a step that expects a log line waits for it; design flows stay headless"),
161
+ web: bool("run the file scenarios on the web player, in a browser (needs Playwright): as --lite, on the runtime as wasm; no `fill` step (tap a field and `type`)"),
160
162
  desktop: bool("run the file scenarios through the NATIVE host: the same steps, targets and expects as headless (expect.audio and the json output excepted); design flows stay headless. --frames / --fixed-dt as in render --desktop"),
161
163
  }
162
164
  // (`sim` comes with desktopRenderFlags: the file scenarios through the WINDOWLESS host — no GPU, no
@@ -168,7 +170,7 @@ export default defineCommand({
168
170
  name: "test",
169
171
  summary: "Run the flow tests (tests/*.flow.json + design-derived flows)",
170
172
  usage: "[name…]",
171
- description: "Headless flow tests: tests/*.flow.json scenarios, plus flows derived from the design graph (design/meta.json activator edges — checks the built app against the designed navigation; needs named elements + screen roots). Failing steps dump the frame (JSON + PNG) under tests/.artifacts/<flow>/.\n\nDesign flows read tests/fixtures.json automatically (canned fetch responses) so backend-gated transitions run headless; file scenarios declare their own via the \"fixtures\" envelope. [name…] runs only those files (a path, or a name under the tests folder).",
173
+ description: "Headless flow tests: tests/*.flow.json scenarios, plus flows derived from the design graph (design/meta.json activator edges — checks the built app against the designed navigation; needs named elements + screen roots). Failing steps dump the frame (JSON + PNG) under tests/.artifacts/<flow>/. On --desktop / --lite / --web a project with *.server.ts files gets its backend run for the tests, on a database of its own (.lecodes/test-db) that is emptied before every case. The headless renderer has no backend, and --sim does not wait for one: its frames are not tied to real time.\n\nDesign flows read tests/fixtures.json automatically (canned fetch responses) so backend-gated transitions run headless; file scenarios declare their own via the \"fixtures\" envelope. [name…] runs only those files (a path, or a name under the tests folder).",
172
174
  flags: testFlags,
173
175
  examples: ["lecodes test", "lecodes test hero --logs", "lecodes test --flows --json", "lecodes test --desktop"],
174
176
  run: ({ args, flags }) => runTests(args, flags),
@@ -177,9 +179,12 @@ export default defineCommand({
177
179
  const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
178
180
  // --sim is the native path on the windowless host: everything --desktop does, minus pixels.
179
181
  const sim = flags.sim
180
- const lite = flags.lite
181
- if (lite && (flags.desktop || sim)) throw new CliError("--lite is a host of its own — drop --desktop / --sim.")
182
- if (lite && flags.flows) throw new CliError("--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop --lite.")
182
+ // --lite / --web: a browser run, on Web Lite or on the web player — one runner, two pages.
183
+ if (flags.lite && flags.web) throw new CliError("--lite and --web are two hosts — pick one.")
184
+ const lite = flags.lite || flags.web
185
+ const browserFlag = flags.web ? "--web" : "--lite"
186
+ if (lite && (flags.desktop || sim)) throw new CliError(`${browserFlag} is a host of its own — drop --desktop / --sim.`)
187
+ if (lite && flags.flows) throw new CliError(`--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop ${browserFlag}.`)
183
188
  const desktop = flags.desktop || sim
184
189
  if (desktop && flags.flows) throw new CliError(`--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop --${sim ? "sim" : "desktop"}.`)
185
190
  // --desktop: ONE renderer decision (host binary + the staged .filamat variant). The root is
@@ -187,7 +192,9 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
187
192
  let desktopRoot: string
188
193
  try { desktopRoot = findProjectRoot(process.cwd()) } catch { desktopRoot = process.cwd() }
189
194
  const desktopRenderer = desktop ? resolveDesktopRenderer(desktopRoot, flags.renderer) : undefined
190
- const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer }) : null
195
+ // The project's backend, when it has one: on a database of its own, emptied before every case.
196
+ const backend = desktop ? runBackend({ root: desktopRoot, command: "lecodes test", test: true, log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } }) : null
197
+ const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer, backend: backend! }) : null
191
198
  const headless = desktop || lite ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
192
199
  const warnings = headless?.warnings ?? []
193
200
  const useLocalAssets = headless?.useLocalAssets ?? false
@@ -338,11 +345,17 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
338
345
  // Web Lite: one compile, one browser; a page of its own per case.
339
346
  let liteSession: LiteSession | null = null
340
347
  if (lite) {
341
- const page = resolveLiteRunner(note)
342
- if (!page) throw new CliError("No Web Lite page found: this CLI has no runtime/web-lite (a monorepo checkout needs `bun run build` in hosts/web-lite).")
343
348
  let manifest: Manifest | null = null
344
349
  try { manifest = readManifest(root) } catch { /* a folder standing alone */ }
345
- liteSession = await startLiteSession({ root, manifest, name: manifest?.name ?? "app", lite: page, entry: flags.entry })
350
+ if (flags.web) {
351
+ const web = resolveWebRunner(note)
352
+ if (!web) throw new CliError("No web player found: this CLI has no runtime/web (a monorepo checkout needs `bun run build` in hosts/web-player).")
353
+ liteSession = await startLiteSession({ root, manifest, name: manifest?.name ?? "app", web, entry: flags.entry })
354
+ } else {
355
+ const page = resolveLiteRunner(note)
356
+ if (!page) throw new CliError("No Web Lite page found: this CLI has no runtime/web-lite (a monorepo checkout needs `bun run build` in hosts/web-lite).")
357
+ liteSession = await startLiteSession({ root, manifest, name: manifest?.name ?? "app", lite: page, entry: flags.entry })
358
+ }
346
359
  }
347
360
  const runLiteCase = async (kase: FlowCase): Promise<CaseResult> => {
348
361
  const caseDir = resolve(artifactsRoot, slug(kase.name))
@@ -364,6 +377,8 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
364
377
 
365
378
  for (const kase of cases) {
366
379
  let result: CaseResult
380
+ // every case starts on an empty database, like it starts in a fresh host
381
+ if (backend && results.length > 0) await backend.reset()
367
382
  if (lite) {
368
383
  try {
369
384
  result = await runLiteCase(kase)
@@ -446,6 +461,7 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
446
461
  }
447
462
 
448
463
  await liteSession?.close()
464
+ await backend?.close()
449
465
  clearTimeout(killer)
450
466
  process.exit(failed > 0 ? 1 : 0)
451
467
  }
@@ -7,6 +7,7 @@ import { CliError, bool, c, defineCommand, log, note, readFlags, type Command }
7
7
  import { OPTIONAL_PEERS, peerSource, peerVersion } from "../../hosts/peers"
8
8
  import { installPeers } from "../../hosts/peerInstall"
9
9
  import { archiveSpec, archivedVersion } from "../../hosts/archives"
10
+ import { commandLine, installGlobalArgv, ownManager } from "../../hosts/packageManager"
10
11
  import { updateDesktop } from "../../hosts/desktopRenderer"
11
12
  import { updateMatc } from "../../hosts/matcTool"
12
13
  import { refreshTypes } from "./types"
@@ -94,7 +95,7 @@ const STANDALONE = existsSync(join(dirname(process.execPath), "runtime")) &&
94
95
  export default defineCommand({
95
96
  name: "update",
96
97
  summary: "Move everything the project depends on to its newest published state",
97
- description: "In dependency order: the desktop host (`desktop update`), matc (`shaders update`), the project's shader compiles, the IDE types (.lecodes/types/ + tsconfig.json, from the server — the former `lecodes types`), the ios/android shell pins (`app update`), then the GLOBAL npm installs: every installed optional peer (lecodes-headless, lecodes-design, lecodes-3d-editor, lecodes-assets) and this CLI that is behind npm's latest gets `npm install -g`.\n\nPinned tools are skipped, not overridden: LECODES_DESKTOP_EXE / LECODES_MATC mean a local build. Keeps going when a step fails; the summary says which.",
98
+ description: "In dependency order: the desktop host (`desktop update`), matc (`shaders update`), the project's shader compiles, the IDE types (.lecodes/types/ + tsconfig.json, from the server — the former `lecodes types`), the ios/android shell pins (`app update`), then the GLOBAL installs: every installed optional peer (lecodes-headless, lecodes-design, lecodes-3d-editor, lecodes-assets) and this CLI that is behind npm's latest is installed again, with the package manager the CLI came by (`npm install -g`, or `bun add -g` for a CLI installed with bun).\n\nPinned tools are skipped, not overridden: LECODES_DESKTOP_EXE / LECODES_MATC mean a local build. Keeps going when a step fails; the summary says which.",
98
99
  flags: {
99
100
  local: bool("types from the SDK bundled in this CLI"),
100
101
  "no-shells": bool("leave ios/android pins alone (no re-sync)"),
@@ -159,8 +160,8 @@ export default defineCommand({
159
160
  return "ok"
160
161
  })
161
162
 
162
- // 6. this CLI + its optional peers — the GLOBAL npm installs (`npm install -g`, how the CLI and its
163
- // peers are installed): every installed one that is behind npm's latest moves forward. The peers go
163
+ // 6. this CLI + its optional peers — the GLOBAL installs (`npm install -g` or `bun install -g`,
164
+ // whichever this CLI came by: hosts/packageManager.ts): every installed one that is behind npm's latest moves forward. The peers go
164
165
  // first, the CLI itself last — replacing the running CLI's files is fine (this process already
165
166
  // holds its code), but its new version is what the next invocation runs, so nothing after it here.
166
167
  // Peers that are NOT installed stay that way: they're optional, installing one is a choice.
@@ -195,7 +196,7 @@ export default defineCommand({
195
196
  const prefixBehind = behind.filter((r) => r.where === "prefix")
196
197
  const globalSpecs = await Promise.all(behind.filter((r) => r.where !== "prefix").map(async (r) => await archiveSpec(r.name) ?? r.name + "@latest"))
197
198
  if (flags.check) {
198
- return ["updated", behind.length + " behind — run: lecodes update" + (globalSpecs.length ? " (global: npm install -g " + globalSpecs.join(" ") + ")" : "")]
199
+ return ["updated", behind.length + " behind — run: lecodes update" + (globalSpecs.length ? " (global: " + commandLine(ownManager(), installGlobalArgv(ownManager(), globalSpecs)) + ")" : "")]
199
200
  }
200
201
  const done: string[] = []
201
202
  if (prefixBehind.length) {
@@ -207,9 +208,13 @@ export default defineCommand({
207
208
  }
208
209
  const toInstall = STANDALONE ? globalSpecs.filter((s) => !s.startsWith("lecodes-cli@")) : globalSpecs
209
210
  if (toInstall.length) {
210
- log(" " + c.dim("$") + " npm install -g " + toInstall.join(" "))
211
- const r = spawnSync("npm", ["install", "-g", ...toInstall], { stdio: "inherit", shell: true })
212
- if (r.status !== 0) throw new CliError("npm install -g exited with " + (r.status ?? "a signal"))
211
+ // with the manager this CLI was installed by: another one's global dir is another copy
212
+ const tool = ownManager()
213
+ const argv = installGlobalArgv(tool, toInstall)
214
+ log(" " + c.dim("$") + " " + commandLine(tool, argv))
215
+ const r = spawnSync(tool, argv, { stdio: "inherit", shell: true })
216
+ if (r.error) throw new CliError(tool + " could not run: " + r.error.message)
217
+ if (r.status !== 0) throw new CliError(commandLine(tool, argv.slice(0, 2)) + " exited with " + (r.status ?? "a signal"))
213
218
  done.push(...behind.filter((r) => r.where !== "prefix" && (!STANDALONE || r.name !== "lecodes-cli")).map((r) => r.name))
214
219
  }
215
220
  if (done.length === 0) return ["skipped", "only the standalone CLI itself is behind — download the release"]
@@ -87,7 +87,7 @@ export const refreshTypes = async (opts: RefreshTypesOptions): Promise<void> =>
87
87
  // The compiler uses the SDK vendored in this CLI, not these declarations — worth saying when the
88
88
  // server's surface is a different one, since that gap shows up as "autocompletes but won't build".
89
89
  if (!opts.local && local && bundleHash(local) !== bundleHash(bundle)) {
90
- warn("The server's SDK surface differs from the one this CLI compiles with — if something autocompletes but fails to compile, update the CLI (`npm install -g lecodes-cli@latest`) or re-run with --local.")
90
+ warn("The server's SDK surface differs from the one this CLI compiles with — if something autocompletes but fails to compile, update the CLI (`lecodes update`) or re-run with --local.")
91
91
  }
92
92
 
93
93
  log("")
@@ -3,7 +3,12 @@ import { createRequire } from "node:module"
3
3
  import { dirname, join } from "node:path"
4
4
  import type { FileKind } from "../project/manifest"
5
5
  import { distRoot } from "../hosts/distRoot"
6
- import { compileProject as sdkCompileProject, detectEntry, scanModulesChisel, localAssetNames, ENTRY_CONVENTION } from "lecodes-sdk/compile"
6
+ import { installPeers } from "../hosts/peerInstall"
7
+ import { peerDirInPrefix } from "../hosts/peers"
8
+ import {
9
+ compileProject as sdkCompileProject, compileServerBundle, detectEntry, isServerPath, scanModulesChisel, localAssetNames, ENTRY_CONVENTION,
10
+ type CompileServerOptions,
11
+ } from "lecodes-sdk/compile"
7
12
 
8
13
  // Every project compiles through the SDK's one import-free compiler, which bundles with chisel (the
9
14
  // Rust method-granular bundler, @letary/chisel) — the only bundler. The SDK resolves the chisel
@@ -84,6 +89,9 @@ export type CompileOptions = {
84
89
  /** What the compile has to say that is not an error (a plugin the app uses and app.json does not
85
90
  * list). */
86
91
  onWarning?: (message: string) => void
92
+ /** Base URL of the project's backend, baked into the stubs of its `*.server.ts` modules; absent =
93
+ * every call to the server rejects (status 0). */
94
+ serverUrl?: string
87
95
  }
88
96
 
89
97
  /** A warning when auto-detection has several import-graph roots and no main.ts to settle it —
@@ -110,6 +118,45 @@ export const compileProject = (opts: CompileOptions): Promise<string> =>
110
118
  sourcemap: opts.sourcemap,
111
119
  editor: opts.editor,
112
120
  onWarning: opts.onWarning,
121
+ serverUrl: opts.serverUrl,
122
+ })
123
+
124
+ /** Does the project have a server side (`*.server.ts` files)? */
125
+ export const hasServerFiles = (entries: CompileEntry[]): boolean =>
126
+ entries.some((e) => e.type === "text" && isServerPath(e.path))
127
+
128
+ type Typescript = NonNullable<CompileServerOptions["typescript"]>
129
+
130
+ /** TypeScript with its lib files on disk — the server code is checked, and the endpoint validators
131
+ * derived, by its checker. The project's own, else one this CLI's install can see, else the CLI's
132
+ * peer (installed on first use, like the database engine). One WITHOUT the compiler API is passed
133
+ * over: TypeScript 7 — what `install typescript` brings today — is the native compiler, with no
134
+ * `createProgram` to call. */
135
+ const findTypescript = async (root: string): Promise<Typescript> => {
136
+ const from = (at: string): Typescript | null => {
137
+ try {
138
+ const mod = createRequire(at)("typescript")
139
+ return mod?.createProgram ? mod : mod?.default?.createProgram ? mod.default : null
140
+ } catch { return null }
141
+ }
142
+ const prefix = join(peerDirInPrefix("typescript"), "package.json")
143
+ const found = from(join(root, "package.json")) ?? from(import.meta.url) ?? from(prefix)
144
+ if (found) return found
145
+ await installPeers(["typescript"], "The project's server code (*.server.ts)")
146
+ return from(prefix) ?? (() => { throw new Error(`The typescript installed in ${peerDirInPrefix("typescript")} has no compiler API`) })()
147
+ }
148
+
149
+ /**
150
+ * The project's server bundle (its `*.server.ts` files and what they import), or null when it has
151
+ * none. `app` = app.json's name (the sign-in mail). Always checked with TypeScript, the way the
152
+ * le.codes build checks it: an endpoint that is not async, an unawaited `requireUser()` are errors
153
+ * only the checker finds.
154
+ */
155
+ export const compileServer = async (opts: { entries: CompileEntry[], root: string, app?: CompileServerOptions["app"] }) =>
156
+ compileServerBundle({
157
+ entries: opts.entries.map((e) => ({ path: e.path, type: e.type as any, text: e.text })),
158
+ app: opts.app,
159
+ typescript: await findTypescript(opts.root),
113
160
  })
114
161
 
115
162
  /** Re-exported so the CALLERS that stage files next to the bundle name them the way the bundle asks
@@ -12,7 +12,7 @@ import { CliError, note, warnErr } from "../cli"
12
12
  * app's VIEW filter is pinned to le.codes/qr, and an explicit start bypasses filter matching — with
13
13
  * the plain dev.js url: the app's initial.js only fetches a url that ends in ".js", anything else is
14
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`.
15
+ * проекта" screen).
16
16
  *
17
17
  * A device run has no frame cap (unlike the desktop host), so every exit path force-stops the app:
18
18
  * a 3D scene left rendering is a sustained GPU load on a charging phone.
@@ -0,0 +1,138 @@
1
+ import { mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { format } from "node:util"
5
+ import {
6
+ createAuthHost, createInvoker, createSubscribeGuard, describeServer, loadServerModules,
7
+ setChannelPublisher, setDbTransport, type AuthHost, type LoadedServer,
8
+ } from "lecodes-sdk/server"
9
+ import { loadPeer } from "../hosts/peerInstall"
10
+ import type { FromWorker, LogLevel, ToWorker } from "./localBackend"
11
+
12
+ /*
13
+ * The process a project's server bundle runs in under `lecodes dev` — the local twin of the runner's
14
+ * worker (tools/runner/src/worker.ts), started by localBackend.ts as this same CLI with
15
+ * BACKEND_WORKER_ARG and spoken to over the IPC channel. One process per loaded bundle: a reload is
16
+ * a new process, so nothing of the old code (its timers, its module state) outlives it.
17
+ *
18
+ * The database is marcidb's embedded engine (the peer marcidb-embedded), opened here, in the
19
+ * directory the parent names. The schema is synced on every load, the way the runner syncs a draft
20
+ * slot: what the models no longer have is dropped; a change the engine can't migrate in place moves
21
+ * the old database aside and starts an empty one.
22
+ */
23
+
24
+ type Embedded = {
25
+ exec(op: unknown): Promise<any>
26
+ batch(ops: unknown[]): Promise<any[]>
27
+ $sync(schema: string): Promise<null>
28
+ close(): void
29
+ }
30
+ type EmbeddedModule = { openDatabase(dir: string, options?: { disableFsync?: boolean }): Embedded }
31
+
32
+ const post = (m: FromWorker) => { process.send?.(m) }
33
+
34
+ // ── console → the parent (the dev terminal) ──
35
+ const hook = (level: LogLevel) => (...args: unknown[]) => post({ t: "log", level, text: format(...args) })
36
+ console.log = hook("log"); console.info = hook("info"); console.warn = hook("warn"); console.error = hook("error"); console.debug = hook("debug")
37
+ process.on("unhandledRejection", (e) => post({ t: "log", level: "error", text: `Unhandled rejection: ${(e as Error)?.stack ?? e}` }))
38
+ process.on("uncaughtException", (e) => post({ t: "log", level: "error", text: `Uncaught exception: ${(e as Error)?.stack ?? e}` }))
39
+
40
+ /** The code `db.auth.signInWithCode` takes for any email under `lecodes dev` (no `sendCode` needed). */
41
+ const DEV_CODE = "000000"
42
+
43
+ let db: Embedded | null = null
44
+ let invoke: ReturnType<typeof createInvoker> | null = null
45
+ let subscribe: ReturnType<typeof createSubscribeGuard> | null = null
46
+ let authHost: AuthHost | null = null
47
+
48
+ const quit = () => {
49
+ try { db?.close() } catch { /* already closed */ }
50
+ process.exit(0)
51
+ }
52
+ // the dev server went away (Ctrl+C, a crash): nothing is left to serve
53
+ process.on("disconnect", quit)
54
+ // …and when it was killed outright the channel may never say so: a process left behind keeps the
55
+ // database locked, and the next `lecodes dev` of the project could not open it.
56
+ const parent = process.ppid
57
+ setInterval(() => { try { process.kill(parent, 0) } catch { quit() } }, 1000).unref?.()
58
+ process.on("SIGINT", quit)
59
+ process.on("SIGTERM", quit)
60
+
61
+ /** Open the project's database and bring it to `schema`. Returns what the developer should be told. */
62
+ const openDb = async (dir: string, schema: string): Promise<string[]> => {
63
+ const { openDatabase } = await loadPeer<EmbeddedModule>("marcidb-embedded", undefined, { for: "lecodes dev", install: false })
64
+ ?? (() => { throw new Error("marcidb-embedded is not installed") })()
65
+ mkdirSync(dir, { recursive: true })
66
+ try {
67
+ db = openDatabase(dir)
68
+ } catch (e) {
69
+ // one process holds a database: the engine's lock is the sign of a second server on this project
70
+ if (/Locked/i.test((e as Error).message)) throw new Error(`The project's database (${dir}) is in use — this project's backend is already running (another \`lecodes dev\` or \`lecodes desktop run\`). Stop it first.`)
71
+ throw e
72
+ }
73
+ try {
74
+ await db.$sync(schema)
75
+ return []
76
+ } catch (e) {
77
+ if ((e as { kind?: string }).kind !== "bad_request") throw e
78
+ const reason = (e as Error).message
79
+ // Not migratable in place — or not a schema at all. An empty database tells the two apart, so a
80
+ // broken schema never costs the data.
81
+ const probeDir = mkdtempSync(join(tmpdir(), "lecodes-db-"))
82
+ const probe = openDatabase(probeDir, { disableFsync: true })
83
+ try { await probe.$sync(schema) } finally { probe.close(); rmSync(probeDir, { recursive: true, force: true }) }
84
+ db.close()
85
+ const aside = `${dir}-old`
86
+ rmSync(aside, { recursive: true, force: true })
87
+ renameSync(dir, aside)
88
+ mkdirSync(dir, { recursive: true })
89
+ db = openDatabase(dir)
90
+ await db.$sync(schema)
91
+ return [`database started over — the schema change couldn't be migrated (${reason}); the previous data is in ${aside}`]
92
+ }
93
+ }
94
+
95
+ const load = async (m: Extract<ToWorker, { t: "load" }>) => {
96
+ const noDb = () => { throw new Error("db: the database is not open yet — query it inside an endpoint, not while the module loads") }
97
+ setDbTransport({ exec: (op) => (db ?? noDb()).exec(op), batch: (ops) => (db ?? noDb()).batch(ops) })
98
+ setChannelPublisher((ch, group, data) => post({ t: "publish", ch, group, data }))
99
+ // The bundle is a plain script (chisel esm output with only side effects): registers globalThis.__lecodesServer
100
+ ;(0, eval)(m.code)
101
+ const server: LoadedServer = loadServerModules()
102
+ const describe = describeServer(server)
103
+ const notes = describe.marci ? await openDb(m.dbDir, describe.marci) : []
104
+ // Sessions, passwords and email codes over the project's own db, as on the runner. A code that was
105
+ // asked for is printed here, and DEV_CODE signs any email in without one — the developer is the
106
+ // only user.
107
+ authHost = createAuthHost({
108
+ db: server.dbs.find(d => d.$auth) ?? null,
109
+ appName: server.manifest.app?.name,
110
+ echoCodes: true,
111
+ devCode: DEV_CODE,
112
+ log: (level, text) => post({ t: "log", level, text }),
113
+ })
114
+ authHost.install()
115
+ const auth = authHost.enabled ? authHost : null
116
+ const onError = (id: string, e: unknown) => post({ t: "log", level: "error", text: `${id}: ${(e as Error)?.stack ?? e}` })
117
+ invoke = createInvoker(server, { auth, onError })
118
+ subscribe = createSubscribeGuard(server, { auth, onError })
119
+ post({ t: "loaded", describe, notes })
120
+ }
121
+
122
+ process.on("message", async (m: ToWorker) => {
123
+ switch (m.t) {
124
+ case "load":
125
+ try { await load(m) } catch (e) { post({ t: "loadError", message: (e as Error)?.stack ?? String(e) }) }
126
+ return
127
+ case "invoke": {
128
+ const result = invoke ? await invoke(m.id, m.args, m.ctx) : { ok: false as const, status: 503, message: "server not loaded" }
129
+ return post({ t: "result", req: m.req, result })
130
+ }
131
+ case "subscribe": {
132
+ const result = subscribe ? await subscribe(m.ch, m.args, m.ctx) : { ok: false as const, status: 503, message: "server not loaded" }
133
+ return post({ t: "result", req: m.req, result })
134
+ }
135
+ case "stop":
136
+ return quit()
137
+ }
138
+ })