lecodes-cli 2.0.3 → 2.0.5

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 (82) hide show
  1. package/dist/index.js +2580 -872
  2. package/package.json +16 -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 +17 -17
  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-WxiusXCB.js +1 -0
  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-Db5BY7FB.js +1 -0
  26. package/runtime/web-lite/assets/plugins-CuEPHbHL.js +1 -0
  27. package/runtime/web-lite/assets/roboto-cyrillic-DVBNKCCH.woff2 +0 -0
  28. package/runtime/web-lite/assets/roboto-cyrillic-ext-1gPz9dK9.woff2 +0 -0
  29. package/runtime/web-lite/assets/roboto-greek-DVLYEgdx.woff2 +0 -0
  30. package/runtime/web-lite/assets/roboto-greek-ext-D98D44EL.woff2 +0 -0
  31. package/runtime/web-lite/assets/roboto-latin-cAJwcdBQ.woff2 +0 -0
  32. package/runtime/web-lite/assets/roboto-latin-ext-t610Sqxq.woff2 +0 -0
  33. package/runtime/web-lite/assets/roboto-rest-C7JkyNHT.woff2 +0 -0
  34. package/runtime/web-lite/assets/roboto-vietnamese-w-p-nKZZ.woff2 +0 -0
  35. package/runtime/web-lite/assets/scene3d-CX7MAqmN.js +866 -0
  36. package/runtime/web-lite/assets/tween-BnWYhJ7P.js +1 -0
  37. package/runtime/web-lite/assets/video-BLj61nDx.js +1 -0
  38. package/runtime/web-lite/assets/vlist-DrV2Q7P5.js +1 -0
  39. package/runtime/web-lite/assets/worker-Caf-yYEI.js +2 -0
  40. package/runtime/web-lite/features.json +134 -0
  41. package/runtime/web-lite/lite.js +90 -13
  42. package/runtime/web-lite/plugins/camera/index-5XOM8eHl.js +1 -0
  43. package/runtime/web-lite/plugins/geolocation/index-BjTKN9_P.js +1 -0
  44. package/runtime/web-lite/plugins/map/_virtual_lecodes-half-CQBO044A.js +1 -0
  45. package/runtime/web-lite/plugins/map/_virtual_lecodes-half-resources-BYwqQkWQ.js +1 -0
  46. package/runtime/web-lite/plugins/map/assets/maplibre-gl-worker-DeQFNGa4.js +8 -0
  47. package/runtime/web-lite/plugins/map/index-BTBHQ264.js +1 -0
  48. package/runtime/web-lite/plugins/map/mapImpl-C6E1SwCS.js +823 -0
  49. package/runtime/web-lite/plugins/qr-scanner/index-CK_cYoJR.js +1 -0
  50. package/runtime/web-lite/plugins.json +8 -0
  51. package/src/commands/compile.ts +7 -1
  52. package/src/commands/desktop.ts +7 -4
  53. package/src/commands/dev.ts +4 -14
  54. package/src/commands/render.ts +6 -2
  55. package/src/commands/test.ts +25 -9
  56. package/src/commands/update/index.ts +5 -4
  57. package/src/compile/projectCompile.ts +47 -1
  58. package/src/dev/androidDev.ts +1 -1
  59. package/src/dev/backendWorker.ts +138 -0
  60. package/src/dev/devServer.ts +119 -115
  61. package/src/dev/localBackend.ts +214 -0
  62. package/src/dev/projectBackend.ts +199 -0
  63. package/src/dev/serve.ts +164 -0
  64. package/src/hosts/archives.ts +41 -7
  65. package/src/hosts/cmgenTool.ts +6 -9
  66. package/src/hosts/desktopRenderer.ts +6 -5
  67. package/src/hosts/liteRunner.ts +52 -9
  68. package/src/hosts/matcTool.ts +5 -22
  69. package/src/hosts/peerInstall.ts +7 -7
  70. package/src/hosts/peers.ts +1 -0
  71. package/src/hosts/releases.ts +6 -0
  72. package/src/index.ts +5 -1
  73. package/src/plugin/generate.ts +10 -6
  74. package/src/web/build.ts +45 -5
  75. package/runtime/web/assets/creator-2d-D9WKYM6h.js +0 -1
  76. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  77. package/runtime/web/assets/creator-full-BBXzQlhq.js +0 -1
  78. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +0 -8124
  79. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  80. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +0 -1
  81. package/runtime/web-lite/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
  82. package/runtime/web-lite/assets/creatorTween-PF8B6T8V.wasm +0 -0
@@ -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[], onWarning: (w: string) => void) => 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, onWarning) => {
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, onWarning })
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, onWarning)
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)
@@ -5,8 +5,10 @@ import pkg from "../../package.json"
5
5
  * `npm install <url>` puts the package in place under its own name.
6
6
  *
7
7
  * A url names one version, and which one is this CLI's to say: the headless host at the CLI's own
8
- * version (one ABI set), an editor at the version the CLI was published against (its peer range,
9
- * snapped to it by scripts/sync-peer-versions.ts). */
8
+ * version (one ABI set); an editor at the NEWEST version out that the CLI's peer range takes — the
9
+ * package's index.json in the bucket names what is out. So an editor is released with no release
10
+ * of the CLI: `lecodes update` brings it. With the index out of reach it is the range's own
11
+ * version, the one the CLI was published against (scripts/sync-peer-versions.ts). */
10
12
 
11
13
  export const ARCHIVED_PEERS: ReadonlySet<string> = new Set(["lecodes-headless", "lecodes-design", "lecodes-3d-editor"])
12
14
 
@@ -16,17 +18,49 @@ export const packagesBase = (): string => (process.env.LECODES_PACKAGES_URL || B
16
18
 
17
19
  export const archiveUrl = (name: string, version: string): string => `${packagesBase()}/${name}/${name}-${version}.tgz`
18
20
 
21
+ const numbers = (version: string): number[] => version.split(/[.-]/).slice(0, 3).map(Number)
22
+ const byVersion = (a: string, b: string): number => {
23
+ const pa = numbers(a), pb = numbers(b)
24
+ for (let i = 0; i < 3; i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d }
25
+ return 0
26
+ }
27
+
28
+ /** `^floor` takes it: not older, and the same leftmost number that is not zero. */
29
+ export const caretTakes = (floor: string, version: string): boolean => {
30
+ if (!/^\d+\.\d+\.\d+$/.test(version) || byVersion(version, floor) < 0) return false
31
+ const f = numbers(floor), v = numbers(version)
32
+ const held = f[0] ? 1 : f[1] ? 2 : 3
33
+ for (let i = 0; i < held; i++) if (f[i] !== v[i]) return false
34
+ return true
35
+ }
36
+
37
+ const asked = new Map<string, Promise<string[]>>()
38
+ /** What is out of a package, as its index names it; none with the index out of reach. */
39
+ const versionsOut = (name: string): Promise<string[]> => {
40
+ const url = `${packagesBase()}/${name}/index.json`
41
+ let answer = asked.get(url)
42
+ if (!answer) {
43
+ answer = fetch(`${url}?t=${Date.now()}`, { signal: AbortSignal.timeout(4000) })
44
+ .then(async (res) => res.ok ? ((await res.json()) as { versions?: string[] }).versions ?? [] : [])
45
+ .catch(() => [])
46
+ asked.set(url, answer)
47
+ }
48
+ return answer
49
+ }
50
+
19
51
  /** The version of an archived peer this CLI installs; null for a peer that is none. */
20
- export const archivedVersion = (name: string): string | null => {
52
+ export const archivedVersion = async (name: string): Promise<string | null> => {
21
53
  if (!ARCHIVED_PEERS.has(name)) return null
22
54
  if (name === "lecodes-headless") return pkg.version
23
55
  const range = (pkg.peerDependencies as Record<string, string> | undefined)?.[name]
24
- const version = range?.replace(/^[\^~>=\s]+/, "")
25
- return version && /^\d+\.\d+\.\d+/.test(version) ? version : null
56
+ const floor = range?.replace(/^[\^~>=\s]+/, "")
57
+ if (!floor || !/^\d+\.\d+\.\d+/.test(floor)) return null
58
+ const newest = (await versionsOut(name)).filter((version) => caretTakes(floor, version)).sort(byVersion).pop()
59
+ return newest ?? floor
26
60
  }
27
61
 
28
62
  /** What an archived peer is installed by; null for a peer that is none. */
29
- export const archiveSpec = (name: string): string | null => {
30
- const version = archivedVersion(name)
63
+ export const archiveSpec = async (name: string): Promise<string | null> => {
64
+ const version = await archivedVersion(name)
31
65
  return version ? archiveUrl(name, version) : null
32
66
  }
@@ -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