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,199 @@
1
+ import { rmSync } from "node:fs"
2
+ import { join, relative } from "node:path"
3
+ import { createChannelHub } from "lecodes-sdk/server"
4
+ import { compileServer, hasServerFiles, type CompileEntry } from "../compile/projectCompile"
5
+ import { loadPeer } from "../hosts/peerInstall"
6
+ import { LECODES_DIR } from "../project/manifest"
7
+ import { readAppConfigAt } from "../commands/app/shared"
8
+ import { startLocalBackend, type LocalBackend } from "./localBackend"
9
+ import { serve, type Served, type Socket, type SocketHandlers } from "./serve"
10
+
11
+ /*
12
+ * The project's backend ON THE WIRE, on this machine: its server bundle run by localBackend.ts, put
13
+ * behind the routes the SDK's client transport speaks (sdk/src/runtime/rpc.ts) —
14
+ *
15
+ * POST <base>/api/<id> an endpoint call, the envelope always HTTP 200 (the client reads `ok`)
16
+ * WS <base>/ws the channel socket: the SDK's hub, the one the runner serves on le.codes
17
+ *
18
+ * Two users: the dev server mounts the routes under its token path (`lecodes dev`, `web dev`, the
19
+ * browser tests), and `runBackend` listens on a port of its own for the commands that run a bundle
20
+ * on a host this CLI starts (`desktop run`, `serve`, `render --desktop`, `test --desktop`).
21
+ */
22
+
23
+ export type ServerBundle = Awaited<ReturnType<typeof compileServer>>
24
+
25
+ export type ProjectBackendOptions = {
26
+ root: string
27
+ log: (msg: string) => void
28
+ /** The database's folder (default `.lecodes/db`). */
29
+ dbDir?: string
30
+ /** The command, for the note of the database peer's install. */
31
+ command: string
32
+ }
33
+
34
+ export type ProjectBackend = {
35
+ /** The server bundle of these entries; null when the project has no server files. */
36
+ compile: (entries: CompileEntry[]) => Promise<ServerBundle | null>
37
+ /** Run this bundle from now on (null = the project has no server side any more: stop). Rejects
38
+ * when it does not load; resolves to what to tell the developer. */
39
+ run: (bundle: ServerBundle | null) => Promise<string[]>
40
+ /** A request under the backend's base; `route` is the path after it ("/api/<id>"). */
41
+ handle: (req: Request, route: string, ip: string | undefined) => Promise<Response>
42
+ /** The channel socket (`<base>/ws`). */
43
+ socket: (req: Request, ip: string | undefined) => (conn: Socket) => SocketHandlers
44
+ /** Start again on an EMPTY database (a test's case). */
45
+ reset: () => Promise<void>
46
+ /** Resolves once the database is closed. */
47
+ close: () => Promise<void>
48
+ }
49
+
50
+ const CORS = {
51
+ "access-control-allow-origin": "*",
52
+ "access-control-allow-methods": "POST, GET, OPTIONS",
53
+ "access-control-allow-headers": "authorization, content-type",
54
+ "access-control-max-age": "86400",
55
+ }
56
+ const bearerOf = (req: Request): string | null => /^Bearer\s+(.+)$/i.exec(req.headers.get("authorization") ?? "")?.[1]?.trim() ?? null
57
+ const BODY_LIMIT = 8 * 1024 * 1024
58
+
59
+ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBackend => {
60
+ const { root, log } = options
61
+ const dbDir = options.dbDir ?? join(root, LECODES_DIR, "db")
62
+ let backend: LocalBackend | null = null
63
+ let running: string | null = null
64
+
65
+ // The channel socket is the SDK's hub — the one the runner serves too.
66
+ const hub = createChannelHub<Socket>({
67
+ send: (conn, frame) => conn.send(frame),
68
+ subscribe: (_scope, ch, args, ctx) => backend
69
+ ? backend.subscribe(ch, args, ctx)
70
+ : Promise.resolve({ ok: false as const, status: 404, message: "The project has no server files" }),
71
+ })
72
+ const sockets = new Set<Socket>()
73
+ const start = () => startLocalBackend({ root, dbDir, log, onPublish: (ch, group, data) => hub.publish("", ch, group, data) })
74
+
75
+ const envelope = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json", "cache-control": "no-store", ...CORS } })
76
+
77
+ return {
78
+ compile: async (entries) => {
79
+ if (!hasServerFiles(entries)) return null
80
+ // the app's name, for the sign-in mail
81
+ const app = readAppConfigAt(root)
82
+ return compileServer({ entries, root, app: app ? { name: app.name } : undefined })
83
+ },
84
+ run: async (bundle) => {
85
+ if (!bundle) {
86
+ await backend?.close()
87
+ backend = null
88
+ running = null
89
+ return []
90
+ }
91
+ // the embedded database is a peer, installed the first time a project defines models
92
+ if (Object.keys(bundle.manifest.modules).length && /\bdefineDb\s*\(/.test(bundle.code)) {
93
+ await loadPeer("marcidb-embedded", undefined, { for: `${options.command} (the project's database)` })
94
+ }
95
+ backend ??= start()
96
+ const deployed = await backend.deploy(bundle.code)
97
+ running = bundle.code
98
+ if (!deployed.changed) return []
99
+ const d = deployed.describe
100
+ const models = Object.keys(d.models).length
101
+ const count = (n: number, what: string) => `${n} ${what}${n === 1 ? "" : "s"}`
102
+ log(`server: ${count(d.endpoints.length, "endpoint")}, ${count(d.channels.length, "channel")}${models ? `, ${count(models, "model")} (database in ${relative(root, dbDir).replace(/\\/g, "/")})` : ""}`)
103
+ return deployed.notes.map((n) => `server: ${n}`)
104
+ },
105
+ handle: async (req, route, ip) => {
106
+ if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS })
107
+ const id = route.startsWith("/api/") ? decodeURIComponent(route.slice("/api/".length)) : ""
108
+ if (!id || req.method !== "POST") return envelope({ ok: false, status: 404, message: "not an endpoint call" })
109
+ if (!backend) return envelope({ ok: false, status: 404, message: "The project has no server files" })
110
+ let args: unknown
111
+ try {
112
+ const body = await req.text()
113
+ args = body.length > BODY_LIMIT ? null : JSON.parse(body)?.args
114
+ } catch { args = null }
115
+ if (!Array.isArray(args)) return envelope({ ok: false, status: 400, message: "body must be {\"args\":[…]}" })
116
+ return envelope(await backend.invoke(id, args, { headers: Object.fromEntries(req.headers), ip, sessionToken: bearerOf(req) ?? undefined }))
117
+ },
118
+ socket: (req, ip) => (conn) => {
119
+ const c = hub.connect(conn, { ip, session: bearerOf(req) })
120
+ sockets.add(conn)
121
+ return {
122
+ onMessage: (raw) => { void c.message(raw) },
123
+ onClose: () => { sockets.delete(conn); c.close() },
124
+ }
125
+ },
126
+ reset: async () => {
127
+ if (!backend || running === null) return
128
+ await backend.close()
129
+ rmSync(dbDir, { recursive: true, force: true })
130
+ backend = start()
131
+ await backend.deploy(running)
132
+ },
133
+ close: async () => {
134
+ for (const conn of [...sockets]) conn.close()
135
+ await backend?.close()
136
+ backend = null
137
+ },
138
+ }
139
+ }
140
+
141
+ // ───────────────────────────── a backend for one run of a bundle ─────────────────────────────
142
+
143
+ export type RunBackend = {
144
+ /** Called by the compile, once it has the project's entries: the backend is started when there
145
+ * are server files, and its url — what the app's stubs call — is the answer. */
146
+ start: (entries: CompileEntry[], onWarning: (w: string) => void) => Promise<string | undefined>
147
+ /** Start again on an empty database. */
148
+ reset: () => Promise<void>
149
+ close: () => Promise<void>
150
+ }
151
+
152
+ /** A port that stays the same for a project from run to run: the app keeps its session under the
153
+ * backend's url, so a url that moved would sign the developer out at every run. */
154
+ const stablePort = (root: string): number => {
155
+ let h = 0
156
+ for (let i = 0; i < root.length; i++) h = (h * 31 + root.charCodeAt(i)) >>> 0
157
+ return 42000 + (h % 4000)
158
+ }
159
+
160
+ /**
161
+ * The backend of a project run on this machine by a host the CLI starts, listening on
162
+ * 127.0.0.1. `test`: a database of its own (`.lecodes/test-db`, emptied by `reset`) and any free
163
+ * port — a test never touches what `lecodes dev` and `desktop run` keep in `.lecodes/db`.
164
+ */
165
+ export const runBackend = (options: { root: string, command: string, log: (msg: string) => void, test?: boolean }): RunBackend => {
166
+ const { root } = options
167
+ const dbDir = join(root, LECODES_DIR, options.test ? "test-db" : "db")
168
+ if (options.test) rmSync(dbDir, { recursive: true, force: true })
169
+ const backend = createProjectBackend({ root, dbDir, log: options.log, command: options.command })
170
+ let server: Served | null = null
171
+
172
+ const listen = (port: number) => serve({
173
+ port,
174
+ hostname: "127.0.0.1",
175
+ fetch: (req, peer) => {
176
+ const path = new URL(req.url).pathname
177
+ return path.startsWith("/server/") ? backend.handle(req, path.slice("/server".length), peer.ip) : new Response("Not found", { status: 404 })
178
+ },
179
+ websocket: (req, peer) => new URL(req.url).pathname === "/server/ws" ? backend.socket(req, peer.ip) : null,
180
+ onError: (e) => options.log(`request failed: ${e instanceof Error ? e.message : String(e)}`),
181
+ })
182
+
183
+ return {
184
+ start: async (entries, onWarning) => {
185
+ const bundle = await backend.compile(entries)
186
+ if (!bundle) return undefined
187
+ for (const w of await backend.run(bundle)) onWarning(w)
188
+ // the project's port when it is free (two runs of one project at once: the second takes any)
189
+ server ??= await (options.test ? listen(0) : listen(stablePort(root)).catch(() => listen(0)))
190
+ return `http://127.0.0.1:${server.port}/server`
191
+ },
192
+ reset: () => backend.reset(),
193
+ close: async () => {
194
+ server?.close()
195
+ server = null
196
+ await backend.close()
197
+ },
198
+ }
199
+ }
@@ -0,0 +1,164 @@
1
+ import { createServer, type IncomingMessage } from "node:http"
2
+ import { Readable } from "node:stream"
3
+ import { upgradeWebSocket } from "./wsServer"
4
+
5
+ /*
6
+ * ONE way for the CLI to listen: a handler of web-standard requests (`Request` → `Response`) and
7
+ * websockets as text messages. The CLI runs on two runtimes — Node (the npm install) and Bun (a
8
+ * checkout, the standalone binary) — and this file is the only place that knows which:
9
+ *
10
+ * Bun `Bun.serve`: its own HTTP server and websockets. Bun's `node:http` is NOT an option
11
+ * there — it emits 'upgrade' and then drops what is written to the socket, so a websocket
12
+ * never completes its handshake (probed on 1.3.14).
13
+ * Node `node:http` + the dependency-free websocket of ./wsServer.ts.
14
+ *
15
+ * A server of the CLI that needs a websocket is written against this, never against `node:http`.
16
+ */
17
+
18
+ export type Peer = {
19
+ /** The remote address, as the runtime reports it. */
20
+ ip: string | undefined
21
+ }
22
+
23
+ export type Socket = {
24
+ send: (text: string) => void
25
+ close: () => void
26
+ }
27
+
28
+ export type SocketHandlers = {
29
+ onMessage: (text: string) => void
30
+ /** Fired exactly once, however the connection ends. */
31
+ onClose: () => void
32
+ }
33
+
34
+ export type ServeOptions = {
35
+ port: number
36
+ /** Bind address (default 0.0.0.0). */
37
+ hostname?: string
38
+ fetch: (req: Request, peer: Peer) => Response | Promise<Response>
39
+ /** A websocket asks to connect: return what takes the open socket, or null to refuse. Decided
40
+ * from the request alone (its path), before the handshake. */
41
+ websocket?: (req: Request, peer: Peer) => ((socket: Socket) => SocketHandlers) | null
42
+ /** A handler that threw (the response is a 500). */
43
+ onError?: (e: unknown) => void
44
+ }
45
+
46
+ export type Served = {
47
+ /** The bound port (differs from `options.port` when 0 was passed). */
48
+ port: number
49
+ close: () => void
50
+ }
51
+
52
+ const failure = (opts: ServeOptions, e: unknown): Response => {
53
+ opts.onError?.(e)
54
+ return new Response("Internal error", { status: 500, headers: { "content-type": "text/plain", "cache-control": "no-store" } })
55
+ }
56
+
57
+ // ───────────────────────────── Bun ─────────────────────────────
58
+
59
+ type BunSocketData = { accept: (socket: Socket) => SocketHandlers, handlers: SocketHandlers | null }
60
+
61
+ const serveBun = (opts: ServeOptions): Served => {
62
+ let server: ReturnType<typeof Bun.serve<BunSocketData>>
63
+ try {
64
+ server = Bun.serve<BunSocketData>({
65
+ port: opts.port,
66
+ hostname: opts.hostname ?? "0.0.0.0",
67
+ // seconds, the most Bun takes: an endpoint of a project's backend may think for 30
68
+ idleTimeout: 255,
69
+ async fetch(req, srv) {
70
+ const peer: Peer = { ip: srv.requestIP(req)?.address }
71
+ try {
72
+ if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
73
+ const accept = opts.websocket?.(req, peer)
74
+ if (!accept) return new Response("Not found", { status: 404 })
75
+ if (srv.upgrade(req, { data: { accept, handlers: null } })) return undefined as unknown as Response
76
+ return new Response("Expected a websocket upgrade", { status: 400 })
77
+ }
78
+ return await opts.fetch(req, peer)
79
+ } catch (e) {
80
+ return failure(opts, e)
81
+ }
82
+ },
83
+ websocket: {
84
+ // A reload channel is silent for as long as nobody saves: Bun's pings keep it open.
85
+ idleTimeout: 960,
86
+ sendPings: true,
87
+ open(ws) {
88
+ ws.data.handlers = ws.data.accept({
89
+ send: (text) => { if (ws.readyState === 1) ws.send(text) },
90
+ close: () => { ws.close() },
91
+ })
92
+ },
93
+ message(ws, raw) {
94
+ ws.data.handlers?.onMessage(typeof raw === "string" ? raw : new TextDecoder().decode(raw))
95
+ },
96
+ close(ws) {
97
+ const h = ws.data.handlers
98
+ ws.data.handlers = null
99
+ h?.onClose()
100
+ },
101
+ },
102
+ })
103
+ } catch (e) {
104
+ // the code node's listen() reports, which callers match on
105
+ if ((e as { code?: string }).code === "EADDRINUSE") throw new Error(`EADDRINUSE: port ${opts.port} is already in use`)
106
+ throw e
107
+ }
108
+ return { port: server.port ?? opts.port, close: () => { void server.stop(true) } }
109
+ }
110
+
111
+ // ───────────────────────────── Node ─────────────────────────────
112
+
113
+ const requestOf = (req: IncomingMessage, upgrade = false): Request => {
114
+ const headers = new Headers()
115
+ for (const [k, v] of Object.entries(req.headers)) {
116
+ if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(", ") : v)
117
+ }
118
+ const method = req.method ?? "GET"
119
+ const bodyless = upgrade || method === "GET" || method === "HEAD"
120
+ return new Request(new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`).href, {
121
+ method, headers,
122
+ ...(bodyless ? {} : { body: Readable.toWeb(req), duplex: "half" }),
123
+ } as unknown as RequestInit)
124
+ }
125
+
126
+ const serveNode = async (opts: ServeOptions): Promise<Served> => {
127
+ const server = createServer((req, res) => {
128
+ void (async () => {
129
+ let response: Response
130
+ try { response = await opts.fetch(requestOf(req), { ip: req.socket.remoteAddress }) }
131
+ catch (e) { response = failure(opts, e) }
132
+ res.writeHead(response.status, Object.fromEntries(response.headers))
133
+ if (!response.body || req.method === "HEAD") { res.end(); return }
134
+ Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream).on("error", () => res.destroy()).pipe(res)
135
+ })()
136
+ })
137
+ server.on("upgrade", (req, socket) => {
138
+ let accept: ((socket: Socket) => SocketHandlers) | null = null
139
+ try { accept = opts.websocket?.(requestOf(req, true), { ip: req.socket.remoteAddress }) ?? null }
140
+ catch (e) { opts.onError?.(e) }
141
+ if (!accept) { socket.destroy(); return }
142
+ let handlers: SocketHandlers | null = null
143
+ const conn = upgradeWebSocket(req, socket, {
144
+ onMessage: (text) => { handlers?.onMessage(text) },
145
+ onClose: () => { handlers?.onClose() },
146
+ })
147
+ if (conn) handlers = accept(conn)
148
+ })
149
+ await new Promise<void>((resolve, reject) => {
150
+ server.once("error", reject)
151
+ server.listen(opts.port, opts.hostname ?? "0.0.0.0", () => {
152
+ server.removeListener("error", reject)
153
+ resolve()
154
+ })
155
+ })
156
+ const address = server.address()
157
+ return {
158
+ port: typeof address === "object" && address !== null ? address.port : opts.port,
159
+ close: () => { server.close(); server.closeAllConnections?.() },
160
+ }
161
+ }
162
+
163
+ export const serve = async (opts: ServeOptions): Promise<Served> =>
164
+ typeof (globalThis as { Bun?: unknown }).Bun !== "undefined" ? serveBun(opts) : serveNode(opts)
@@ -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, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
4
+ import { downloadReleaseArchive, fetchReleaseProduct, macPlatformKey, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
5
5
  import { CliError, note } from "../cli"
6
6
 
7
7
  /*
@@ -11,9 +11,9 @@ import { CliError, note } from "../cli"
11
11
  *
12
12
  * The binary is NOT shipped with the CLI (most projects never bake an environment). It downloads on
13
13
  * first use from the same S3 release bucket as the desktop renderer and matc (product "cmgen" in
14
- * releases/versions.json — published by scripts/release/release-windows.ps1 from the desktop
15
- * build's in-tree filament host tools, so the KTX it writes always matches the engine the hosts
16
- * ship). Cache: ~/.lecodes/cmgen/cmgen-<version>/. LECODES_CMGEN overrides resolution entirely
14
+ * releases/versions.json — published by scripts/release/release-windows.ps1 / release-ubuntu.sh /
15
+ * release-macos.sh from the desktop build's in-tree filament host tools, so the KTX it writes always
16
+ * matches the engine the hosts ship). Cache: ~/.lecodes/cmgen/cmgen-<version>/. LECODES_CMGEN overrides resolution entirely
17
17
  * (local dev: hosts/desktop/build/creator-gl/filament/tools/cmgen/cmgen.exe).
18
18
  *
19
19
  * The CLI resolves it and hands the path to lecodes-assets through LECODES_CMGEN, so that package
@@ -29,11 +29,8 @@ const exeName = () => (process.platform === "win32" ? "cmgen.exe" : "cmgen")
29
29
  const platformKey = (): string => {
30
30
  if (process.platform === "win32") return "windows-x64"
31
31
  if (process.platform === "linux") return "linux-x64"
32
- throw new CliError(
33
- process.platform === "darwin"
34
- ? "cmgen isn't published for macOS yet — build filament's host tools locally and set LECODES_CMGEN."
35
- : `cmgen isn't published for ${process.platform}.`,
36
- )
32
+ if (process.platform === "darwin") return macPlatformKey()
33
+ throw new CliError(`cmgen isn't published for ${process.platform}.`)
37
34
  }
38
35
 
39
36
  const cacheRoot = () => join(homedir(), ".lecodes", "cmgen")
@@ -2,7 +2,7 @@ import { execFileSync, spawn, spawnSync } from "node:child_process"
2
2
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, linkSync } from "node:fs"
3
3
  import { homedir, tmpdir } from "node:os"
4
4
  import { delimiter, join, resolve } from "node:path"
5
- import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
5
+ import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, macPlatformKey, semverNewer, type ManifestVersion } from "./releases"
6
6
  import { CliError, note, warnErr } from "../cli"
7
7
  import { makeStackDemangler } from "../compile/nativeStack"
8
8
 
@@ -17,9 +17,10 @@ import { makeStackDemangler } from "../compile/nativeStack"
17
17
  * ~/.lecodes/renderer/desktop-<version>/. LECODES_DESKTOP_EXE overrides resolution entirely
18
18
  * (local dev: point it at hosts/desktop/build/lecodes-desktop.exe).
19
19
  *
20
- * Platforms: windows-x64 + linux (the ubuntu build) + macos-arm64. The macOS host is built and
21
- * published separately (the Apple repo's LeCodes Desktop.app, via scripts/release/release-macos.sh)
22
- * as a notarized DMG under the same "desktop" product — so its version line has gaps: a release
20
+ * Platforms: windows-x64 + linux (the ubuntu build) + macos-arm64 / macos-x64. The macOS host is
21
+ * built and published separately (hosts/desktop's LeCodes Desktop.app, one per CPU, via
22
+ * scripts/release/release-macos.sh) as a notarized DMG under the same "desktop" product — so its
23
+ * version line has gaps: a release
23
24
  * may ship for Windows only. On darwin the cache therefore holds the newest published version
24
25
  * that HAS a macOS artifact (never below the pin), and an app installed in /Applications is only
25
26
  * the fallback for when the manifest can't be reached. The headless render contract
@@ -52,7 +53,7 @@ const platformKey = (renderer: DesktopRenderer = "gl"): string => {
52
53
  throw new CliError(`The Vulkan desktop host is Windows-only (this is ${process.platform}). Use the default renderer.`)
53
54
  }
54
55
  if (process.platform === "linux") return "ubuntu-linux-x64"
55
- if (process.platform === "darwin") return process.arch === "arm64" ? "macos-arm64" : "macos-x64"
56
+ if (process.platform === "darwin") return macPlatformKey()
56
57
  throw new CliError(`The LeCodes Desktop host isn't available on ${process.platform}.`)
57
58
  }
58
59
 
@@ -1,16 +1,17 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process"
2
- import { existsSync, mkdirSync, writeFileSync } from "node:fs"
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
3
3
  import { createServer } from "node:net"
4
4
  import { dirname, join } from "node:path"
5
5
  import { createInterface } from "node:readline"
6
6
  import { CliError } from "../cli"
7
7
  import { startDevServer, type DevServer } from "../dev/devServer"
8
- import type { Manifest } from "../project/manifest"
8
+ import type { WebRunner } from "../dev/webRunner"
9
+ import { LECODES_DIR, type Manifest } from "../project/manifest"
9
10
  import { distRoot } from "./distRoot"
10
11
  import { peersDir } from "./peers"
11
12
  import type { DesktopPlan } from "./desktopScript"
12
13
  // (a relative source import, as desktopScript.ts does: lecodes-headless is an optional peer)
13
- import { findDumpTarget, parseTreeDump } from "../../../../runtime/headless/src/headless/paintDump"
14
+ import { findDumpTarget, parseTreeDump, type DumpNode } from "../../../../runtime/headless/src/headless/paintDump"
14
15
 
15
16
  /*
16
17
  * `lecodes test --lite`: a flow scenario on WEB LITE, in a real browser. The scenario is the one
@@ -135,11 +136,15 @@ export type LiteSession = {
135
136
  }
136
137
 
137
138
  /** The project compiled and served with the page of Web Lite, and a browser to open it in. */
138
- export const startLiteSession = async (options: { root: string, manifest: Manifest | null, name: string, lite: string, entry?: string }): Promise<LiteSession> => {
139
+ export const startLiteSession = async (options: { root: string, manifest: Manifest | null, name: string, lite?: string, web?: WebRunner, entry?: string }): Promise<LiteSession> => {
139
140
  const port = await freePort()
141
+ // The project's backend, when it has one: a database of the tests' own, emptied before every case.
142
+ const dbDir = join(options.root, LECODES_DIR, "test-db")
143
+ rmSync(dbDir, { recursive: true, force: true })
144
+ let ran = 0
140
145
  const server: DevServer = await startDevServer({
141
- root: options.root, manifest: options.manifest, name: options.name, lite: options.lite,
142
- origin: `http://127.0.0.1:${port}`, bind: "127.0.0.1", port, token: "test", entryOverride: options.entry,
146
+ root: options.root, manifest: options.manifest, name: options.name, lite: options.lite, web: options.web,
147
+ origin: `http://127.0.0.1:${port}`, bind: "127.0.0.1", port, token: "test", entryOverride: options.entry, dbDir,
143
148
  })
144
149
  const compiled = await server.recompile()
145
150
  if (!compiled.ok) { server.close(); throw new CliError(`Compile failed:\n${compiled.error}`) }
@@ -153,7 +158,13 @@ export const startLiteSession = async (options: { root: string, manifest: Manife
153
158
  }
154
159
 
155
160
  return {
156
- run: (plan, runOptions) => runPlan(driver, `${server.liteUrl}?dev=..&test`, plan, runOptions),
161
+ // the web player (`web`): the same contract on its page — `window.lecodesTest` under `?test`
162
+ run: async (plan, runOptions) => {
163
+ if (ran++ > 0) await server.resetBackend()
164
+ return options.web
165
+ ? runPlan(driver, `${server.webUrl}player/?dev=../..&test`, plan, runOptions, "web")
166
+ : runPlan(driver, `${server.liteUrl}?dev=..&test`, plan, runOptions, "lite")
167
+ },
157
168
  async close() {
158
169
  await driver.close()
159
170
  server.close()
@@ -203,7 +214,7 @@ const FRAME = "new Promise((r) => requestAnimationFrame(() => requestAnimationFr
203
214
 
204
215
  const FILL_VALUE = `(f) => f.type === "email" ? "test@example.com" : f.type === "tel" ? "5550100" : f.inputMode === "numeric" || f.inputMode === "decimal" ? "1" : f.type === "date" ? "2026-01-01" : f.type === "time" ? "12:00" : "test"`
205
216
 
206
- const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options: LiteRunOptions): Promise<string> => {
217
+ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options: LiteRunOptions, page: "lite" | "web"): Promise<string> => {
207
218
  const output: string[] = []
208
219
  const seen: string[] = []
209
220
  const say = (line: string) => { output.push(line) }
@@ -294,6 +305,30 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
294
305
  case "type": await driver.keyboard("type", unwire(rest)); await frame(); return
295
306
  case "fill": {
296
307
  const value = rest === "" ? undefined : unwire(rest)
308
+ if (page === "web") {
309
+ // The player paints its fields: every empty one on show, in the tree's order, is tapped
310
+ // (a live <input> goes over it) and typed into.
311
+ const fields: DumpNode[] = []
312
+ const walk = (node: DumpNode) => {
313
+ if ((node.type === "input" || node.type === "textarea") && !node.value) fields.push(node)
314
+ for (const child of node.children ?? []) walk(child)
315
+ for (const p of node.pages ?? []) walk(p.node)
316
+ }
317
+ for (const root of parseTreeDump(await dump()).roots) walk(root.node)
318
+ for (const field of fields) {
319
+ const rect = await rectOf(field.id)
320
+ if (!rect || rect[2] === 0 || rect[3] === 0) continue // not on show (a covered page)
321
+ const kind = String(field.paint?.type ?? "")
322
+ const text = value ?? (kind === "email" ? "test@example.com" : kind === "tel" || kind === "phone" ? "5550100" : kind === "number" || kind === "decimal" ? "1" : "test")
323
+ await driver.mouse("move", rect[0] + rect[2] / 2, rect[1] + rect[3] / 2)
324
+ await driver.mouse("down")
325
+ await driver.mouse("up")
326
+ await frame()
327
+ await driver.keyboard("type", text)
328
+ }
329
+ await frame()
330
+ return
331
+ }
297
332
  // every empty field on show, in the tree's order: what the native hosts fill
298
333
  const count = await driver.evaluate<number>(`(() => {
299
334
  window.__lecodesFill = [...document.querySelectorAll("${ON_SHOW} input.lc, ${ON_SHOW} textarea.lc")].filter((f) => f.value === "")
@@ -310,6 +345,14 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
310
345
  case "scroll": {
311
346
  const by = Number(args[0])
312
347
  const name = typeof args[1] === "string" ? args[1] : null
348
+ if (page === "web") {
349
+ // the player scrolls what is under the pointer: the wheel over the named node, or the middle of the page
350
+ const point = name === null ? [options.width / 2, options.height / 2] as [number, number] : (await pointOf(args, 1)).point
351
+ await driver.mouse("move", point[0], point[1])
352
+ await driver.mouse("wheel", 0, by)
353
+ await frame()
354
+ return
355
+ }
313
356
  const target = name === null
314
357
  ? `document.querySelector("${ON_SHOW} .lc-scrollable, ${ON_SHOW} .lc-vlist")`
315
358
  : `document.querySelector('[data-node="${(await nodeOf(name)).id}"]')`
@@ -355,7 +398,7 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
355
398
  return
356
399
  }
357
400
  default:
358
- throw new Error(`"${op}" is not available on Web Lite (it has no 3D scene, no gamepad)`)
401
+ throw new Error(`"${op}" is not available in a browser run (no inspection camera, no gamepad)`)
359
402
  }
360
403
  }
361
404
 
@@ -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, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
4
+ import { downloadReleaseArchive, fetchReleaseProduct, macPlatformKey, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
5
5
  import { CliError, note } from "../cli"
6
6
 
7
7
  /*
@@ -23,14 +23,9 @@ import { CliError, note } from "../cli"
23
23
  * read "not published" with a perfectly good older build sitting right there. `lecodes shaders
24
24
  * update` moves an existing cache forward after an engine upgrade.
25
25
  *
26
- * Platforms: windows-x64, linux-x64, macos-arm64. On Windows/Linux matc rides the DESKTOP build
27
- * (filament's host tools build in-tree there). macOS has no such tree — release-macos.sh builds the
28
- * app from the vendored xcframeworks — so its matc comes from creator-gl's own host-tool build
29
- * (`out/cmake-release/filament/tools/matc`), which is the same filament tree those xcframeworks are
30
- * compiled from. Same guarantee, different source; see scripts/release/release-macos.sh.
31
- *
32
- * macos-x64 is deliberately absent: the host tools are built for the machine doing the release, and
33
- * only Apple Silicon is built today (release-macos.sh skips Intel for the same reason).
26
+ * Platforms: windows-x64, linux-x64, macos-arm64, macos-x64. matc rides the DESKTOP build on every
27
+ * OS (filament's host tools build in-tree there): on the Mac, the two trees of
28
+ * hosts/desktop/build-macos.sh — see scripts/release/release-macos.sh.
34
29
  */
35
30
 
36
31
  const matcName = () => (process.platform === "win32" ? "matc.exe" : "matc")
@@ -38,19 +33,7 @@ const matcName = () => (process.platform === "win32" ? "matc.exe" : "matc")
38
33
  const platformKey = (): string => {
39
34
  if (process.platform === "win32") return "windows-x64"
40
35
  if (process.platform === "linux") return "linux-x64"
41
- if (process.platform === "darwin") {
42
- // Intel Macs have no published matc (see the platform note above). Rosetta would run an
43
- // arm64 binary anyway, but publishing a key we never build is how a download turns into a
44
- // corrupt-archive error instead of a readable one.
45
- if (process.arch !== "arm64") {
46
- throw new CliError(
47
- "matc isn't published for Intel macOS — set LECODES_MATC to a locally built matc " +
48
- "(creator-gl/out/cmake-release/filament/tools/matc), or push the project once so shaders " +
49
- "compile on the server.",
50
- )
51
- }
52
- return "macos-arm64"
53
- }
36
+ if (process.platform === "darwin") return macPlatformKey()
54
37
  throw new CliError(`matc isn't published for ${process.platform}.`)
55
38
  }
56
39
 
@@ -0,0 +1,45 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { homedir } from "node:os"
3
+ import { join, resolve, sep } from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+
6
+ /*
7
+ * ONE answer to "which package manager installs things for this CLI": the one the CLI itself was
8
+ * installed with. A CLI under bun's global dir (`bun install -g lecodes-cli`) installs its peers and
9
+ * updates itself with bun; any other install goes through npm. Mixing them is the failure this
10
+ * removes: `npm install -g lecodes-cli@latest` beside a bun-installed CLI puts a SECOND copy in npm's
11
+ * prefix and leaves the `lecodes` on PATH as old as it was.
12
+ */
13
+ export type PackageManager = "npm" | "bun"
14
+
15
+ const onPath = (cmd: string): boolean => {
16
+ try { return spawnSync(cmd, ["--version"], { stdio: "ignore", shell: process.platform === "win32" }).status === 0 } catch { return false }
17
+ }
18
+
19
+ /** Bun's global install dir (`BUN_INSTALL` moves it). */
20
+ const bunGlobalDir = (): string => join(process.env.BUN_INSTALL ? resolve(process.env.BUN_INSTALL) : join(homedir(), ".bun"), "install", "global")
21
+
22
+ /** The manager that owns the install this file runs from. */
23
+ export const ownManager = (file = fileURLToPath(import.meta.url)): PackageManager => {
24
+ const norm = (p: string) => process.platform === "win32" ? p.toLowerCase() : p
25
+ return norm(resolve(file)).startsWith(norm(bunGlobalDir()) + sep) ? "bun" : "npm"
26
+ }
27
+
28
+ /** The manager to install with: the CLI's own, else whichever of the two is on PATH; null = neither. */
29
+ export const pickManager = (): PackageManager | null => {
30
+ const own = ownManager()
31
+ const other = own === "npm" ? "bun" : "npm"
32
+ return onPath(own) ? own : onPath(other) ? other : null
33
+ }
34
+
35
+ /** `<tool> <argv>` that installs `specs` into the directory `dir` (a private prefix with a package.json). */
36
+ export const installIntoArgv = (tool: PackageManager, dir: string, specs: string[]): string[] =>
37
+ tool === "npm" ? ["install", "--prefix", dir, "--no-audit", "--no-fund", "--loglevel=error", ...specs] : ["add", "--cwd", dir, ...specs]
38
+
39
+ /** `<tool> <argv>` that installs `specs` globally. */
40
+ export const installGlobalArgv = (tool: PackageManager, specs: string[]): string[] =>
41
+ tool === "npm" ? ["install", "-g", ...specs] : ["add", "-g", ...specs]
42
+
43
+ /** The same, as a line a person can run. */
44
+ export const commandLine = (tool: PackageManager, argv: string[]): string =>
45
+ `${tool} ${argv.map((a) => /\s/.test(a) ? `"${a}"` : a).join(" ")}`