lecodes-cli 2.5.1 → 2.8.0

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 (36) hide show
  1. package/dist/index.js +20618 -19047
  2. package/package.json +15 -18
  3. package/runtime/materials/foliage-cards.mat +16 -2
  4. package/runtime/materials/foliage-impostor.mat +103 -26
  5. package/runtime/materials/foliage.mat +12 -1
  6. package/runtime/materials/grass.mat +6 -3
  7. package/runtime/sdk-types.json +1 -1
  8. package/runtime/web/VERSION.json +3 -3
  9. package/runtime/web/assets/{materials-Cs_z1zLi.bin → materials-DE8QZ7Hn.bin} +0 -0
  10. package/runtime/web/assets/materials-world-CGNyYz_-.bin +0 -0
  11. package/runtime/web/player.js +16 -16
  12. package/runtime/web-lite/lite.js +2 -2
  13. package/src/commands/call.ts +96 -0
  14. package/src/commands/compile.ts +8 -4
  15. package/src/commands/desktop.ts +5 -4
  16. package/src/commands/dev.ts +42 -3
  17. package/src/commands/index.ts +2 -0
  18. package/src/commands/init/templates.ts +67 -3
  19. package/src/commands/plugin.ts +1 -1
  20. package/src/commands/render.ts +31 -22
  21. package/src/commands/test.ts +90 -24
  22. package/src/commands/web.ts +13 -4
  23. package/src/compile/headlessBundle.ts +5 -6
  24. package/src/compile/projectCompile.ts +35 -5
  25. package/src/declarations/lecodes-headless.d.ts +51 -2
  26. package/src/dev/backendCall.ts +44 -0
  27. package/src/dev/backendWorker.ts +60 -7
  28. package/src/dev/bots.ts +133 -0
  29. package/src/dev/devLink.ts +136 -0
  30. package/src/dev/devServer.ts +19 -6
  31. package/src/dev/localBackend.ts +57 -9
  32. package/src/dev/projectBackend.ts +227 -49
  33. package/src/hosts/desktopRenderer.ts +8 -2
  34. package/src/hosts/liteRunner.ts +1 -0
  35. package/src/plugin/generate.ts +5 -3
  36. package/runtime/web/assets/materials-world-BtoAfznA.bin +0 -0
@@ -1,6 +1,6 @@
1
1
  import { rmSync } from "node:fs"
2
2
  import { join, relative } from "node:path"
3
- import { createChannelHub, fileResponse, isMultipart, receiveUploads, UploadError, type Upload } from "lecodes-sdk/server"
3
+ import { callerKey, createChannelHub, createRateLimiter, fileResponse, isMultipart, RATE_LIMITS, receiveUploads, TOO_MANY_CALLS, UploadError, type Upload } from "lecodes-sdk/server"
4
4
  import { compileServer, hasServerFiles, type CompileEntry } from "../compile/projectCompile"
5
5
  import { loadPeer } from "../hosts/peerInstall"
6
6
  import { LECODES_DIR, readManifest } from "../project/manifest"
@@ -17,11 +17,22 @@ import { serve, type Served, type Socket, type SocketHandlers } from "./serve"
17
17
  * POST <base>/api/<id> an endpoint call, the envelope always HTTP 200 (the client reads `ok`);
18
18
  * multipart when it carries files
19
19
  * GET <base>/files/<id>/<name> a stored file (`t.file()`): `.lecodes/files`, beside the database
20
- * WS <base>/ws the channel socket: the SDK's hub, the one the runner serves on le.codes
20
+ * WS <base>/ws the channel socket: the SDK's hub, the one the runner serves on le.codes —
21
+ * channels and computed subscriptions (each process that starts runs the live
22
+ * ones again: `hub.rerun`), and who is subscribed where (`watchers`, `members`)
23
+ * GET <base>/status (from this machine only) `{ ok, root }`: whose backend this is — how the other
24
+ * commands find a running `lecodes dev` of their project (devLink.ts)
25
+ * POST <base>/reset (from this machine only) the database starts over empty (`render --fresh`)
26
+ *
27
+ * The limits are the runner's (lecodes-sdk/server rateLimit.ts): an endpoint's calls per (session, id),
28
+ * a session's subscriptions in the hub.
21
29
  *
22
30
  * Two users: the dev server mounts the routes under its token path (`lecodes dev`, `web dev`, the
23
31
  * browser tests), and `runBackend` listens on a port of its own for the commands that run a bundle
24
32
  * on a host this CLI starts (`desktop run`, `serve`, `render --desktop`, `test --desktop`).
33
+ *
34
+ * While a `lecodes dev` of the project runs, it holds the database: the other commands talk to it
35
+ * instead (`devRunBackend`, the dev server's `forwardedBackend` — devLink.ts finds it).
25
36
  */
26
37
 
27
38
  export type ServerBundle = Awaited<ReturnType<typeof compileServer>>
@@ -35,18 +46,20 @@ export type ProjectBackendOptions = {
35
46
  command: string
36
47
  }
37
48
 
38
- export type ProjectBackend = {
49
+ export type ProjectBackend<B = ServerBundle> = {
39
50
  /** The server bundle of these entries; null when the project has no server files. */
40
- compile: (entries: CompileEntry[]) => Promise<ServerBundle | null>
51
+ compile: (entries: CompileEntry[]) => Promise<B | null>
41
52
  /** Run this bundle from now on (null = the project has no server side any more: stop). Rejects
42
53
  * when it does not load; resolves to what to tell the developer. */
43
- run: (bundle: ServerBundle | null) => Promise<string[]>
54
+ run: (bundle: B | null) => Promise<string[]>
44
55
  /** A request under the backend's base; `route` is the path after it ("/api/<id>"). */
45
56
  handle: (req: Request, route: string, ip: string | undefined) => Promise<Response>
46
57
  /** The channel socket (`<base>/ws`). */
47
58
  socket: (req: Request, ip: string | undefined) => (conn: Socket) => SocketHandlers
48
- /** Start again on an EMPTY database (a test's case). */
59
+ /** Start again on an EMPTY database (a test's case), on the machine's clock. */
49
60
  reset: () => Promise<void>
61
+ /** The server's clock reads `offsetMs` ahead of the machine's (a test's case clock; sdk/server `setServerClock`). */
62
+ setClock: (offsetMs: number) => void
50
63
  /** Resolves once the database is closed. */
51
64
  close: () => Promise<void>
52
65
  }
@@ -59,6 +72,24 @@ const CORS = {
59
72
  }
60
73
  const bearerOf = (req: Request): string | null => /^Bearer\s+(.+)$/i.exec(req.headers.get("authorization") ?? "")?.[1]?.trim() ?? null
61
74
  const BODY_LIMIT = 8 * 1024 * 1024
75
+ /** How long a request that came before the first compile waits for it (see `known` below). */
76
+ const FIRST_RUN_WAIT_MS = 30_000
77
+
78
+ /** A peer on this machine: the admin routes answer it alone, and the `x-forwarded-*` of a dev server that
79
+ * forwards to this one (`forwardedBackend`) are believed from it alone. */
80
+ export const isLoopback = (ip: string | undefined): boolean =>
81
+ !!ip && (ip === "::1" || ip.startsWith("127.") || ip.startsWith("::ffff:127."))
82
+
83
+ /** Where a request came in: its own origin, or — from a dev server of this machine that forwards to this
84
+ * one — the origin the device used there (a stored file's url must be one the device reaches). */
85
+ const originOf = (req: Request, ip: string | undefined): string => {
86
+ const host = req.headers.get("x-forwarded-host")
87
+ if (host && isLoopback(ip)) return `${req.headers.get("x-forwarded-proto") === "https" ? "https" : "http"}://${host}`
88
+ return new URL(req.url).origin
89
+ }
90
+ /** The caller's address: a forwarding dev server's word for it, from this machine only. */
91
+ const ipOf = (req: Request, ip: string | undefined): string | undefined =>
92
+ (isLoopback(ip) ? req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() : undefined) || ip
62
93
 
63
94
  export const createProjectBackend = (options: ProjectBackendOptions): ProjectBackend => {
64
95
  const { root, log } = options
@@ -66,13 +97,27 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
66
97
  const filesDir = filesDirOf(dbDir)
67
98
  let backend: LocalBackend | null = null
68
99
  let running: string | null = null
100
+ /** the server's clock offset (`setClock`): a backend started again keeps it */
101
+ let clock = 0
102
+ // Before the first `run` nobody knows yet whether the project has a backend: a device that reconnects to a
103
+ // dev server still compiling (a restart of `lecodes dev`) is answered once it knows — a "no server files" then
104
+ // would be a refusal the device keeps. Bounded: a first compile that fails answers after FIRST_RUN_WAIT_MS.
105
+ let ran = false
106
+ let firstRan!: () => void
107
+ const firstRun = new Promise<void>((r) => { firstRan = r })
108
+ const known = (): Promise<void> => ran ? Promise.resolve() : Promise.race([firstRun, new Promise<void>((r) => { setTimeout(r, FIRST_RUN_WAIT_MS).unref?.() })])
69
109
 
70
110
  // The channel socket is the SDK's hub — the one the runner serves too.
71
111
  const hub = createChannelHub<Socket>({
72
112
  send: (conn, frame) => conn.send(frame),
73
- subscribe: (_scope, ch, args, ctx) => backend
74
- ? backend.subscribe(ch, args, ctx)
75
- : Promise.resolve({ ok: false as const, status: 404, message: "The project has no server files" }),
113
+ subscribe: async (_scope, ch, args, ctx, sub) => {
114
+ await known()
115
+ return backend
116
+ ? backend.subscribe(ch, args, ctx, sub)
117
+ : { ok: false as const, status: 404, message: "The project has no server files" }
118
+ },
119
+ unsubscribe: (_scope, sub) => backend?.unsubscribe(sub),
120
+ presenceChanged: (_scope, id, key) => backend?.presenceChanged(id, key),
76
121
  })
77
122
  const sockets = new Set<Socket>()
78
123
  // `pushChannel.send` reaches the platform's sender through the developer's own login when the checkout is linked —
@@ -80,9 +125,59 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
80
125
  const push = (() => {
81
126
  try { const { apiUrl, token } = session(); return { apiUrl, token, uuid: readManifest(root).uuid } } catch { return null }
82
127
  })()
83
- const start = () => startLocalBackend({ root, dbDir, log, push, onPublish: (ch, group, data) => hub.publish("", ch, group, data) })
128
+ const start = () => {
129
+ const b = startLocalBackend({
130
+ root, dbDir, log, push,
131
+ onPublish: (ch, group, data) => hub.publish("", ch, group, data),
132
+ onStart: () => { void hub.rerun("") },
133
+ presence: (id, key) => hub.presence("", id, key),
134
+ })
135
+ if (clock !== 0) b.setClock(clock)
136
+ return b
137
+ }
84
138
 
85
139
  const envelope = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json", "cache-control": "no-store", ...CORS } })
140
+ /** an endpoint's calls by one caller: the runner's limits */
141
+ const calls = createRateLimiter(RATE_LIMITS.call)
142
+
143
+ const reset = async () => {
144
+ clock = 0
145
+ if (!backend || running === null) return
146
+ await backend.close()
147
+ rmSync(dbDir, { recursive: true, force: true })
148
+ rmSync(filesDir, { recursive: true, force: true })
149
+ backend = start()
150
+ await backend.deploy(running, readAppEnv(root))
151
+ }
152
+
153
+ const runBundle = async (bundle: ServerBundle | null): Promise<string[]> => {
154
+ if (!bundle) {
155
+ await backend?.close()
156
+ backend = null
157
+ running = null
158
+ return []
159
+ }
160
+ // the embedded database is a peer, installed the first time a project defines models
161
+ if (Object.keys(bundle.manifest.modules).length && /\bdefineDb\s*\(/.test(bundle.code)) {
162
+ await loadPeer("marcidb-embedded", undefined, { for: `${options.command} (the project's database)` })
163
+ // …and so is the codec of its pictures, the first time a model has a t.image() field
164
+ if (/\bimage\s*\(/.test(bundle.code)) await loadPeer("sharp", undefined, { for: `${options.command} (the project's t.image() fields)` })
165
+ }
166
+ backend ??= start()
167
+ // the project's keys come from its .env, read on every run: a changed key is a deploy by itself
168
+ const env = readAppEnv(root)
169
+ const deployed = await backend.deploy(bundle.code, env)
170
+ running = bundle.code
171
+ if (!deployed.changed) return []
172
+ const d = deployed.describe
173
+ const models = Object.keys(d.models).length
174
+ const count = (n: number, what: string) => `${n} ${what}${n === 1 ? "" : "s"}`
175
+ const keys = Object.keys(env).sort()
176
+ // a computed is an endpoint too (its one-shot call): counted apart
177
+ const computeds = d.computeds.length
178
+ log(`server: ${count(d.endpoints.length - computeds, "endpoint")}, ${computeds} computed, ${count(d.channels.length, "channel")}${models ? `, ${count(models, "model")} (database in ${relative(root, dbDir).replace(/\\/g, "/")})` : ""}${keys.length ? `, env ${keys.join(", ")} (.env)` : ""}`)
179
+ return deployed.notes.map((n) => `server: ${n}`)
180
+ }
86
181
 
87
182
  return {
88
183
  compile: async (entries) => {
@@ -91,39 +186,27 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
91
186
  const app = readAppConfigAt(root)
92
187
  return compileServer({ entries, root, app: app ? { name: app.name } : undefined })
93
188
  },
94
- run: async (bundle) => {
95
- if (!bundle) {
96
- await backend?.close()
97
- backend = null
98
- running = null
99
- return []
100
- }
101
- // the embedded database is a peer, installed the first time a project defines models
102
- if (Object.keys(bundle.manifest.modules).length && /\bdefineDb\s*\(/.test(bundle.code)) {
103
- await loadPeer("marcidb-embedded", undefined, { for: `${options.command} (the project's database)` })
104
- // …and so is the codec of its pictures, the first time a model has a t.image() field
105
- if (/\bimage\s*\(/.test(bundle.code)) await loadPeer("sharp", undefined, { for: `${options.command} (the project's t.image() fields)` })
106
- }
107
- backend ??= start()
108
- // the project's keys come from its .env, read on every run: a changed key is a deploy by itself
109
- const env = readAppEnv(root)
110
- const deployed = await backend.deploy(bundle.code, env)
111
- running = bundle.code
112
- if (!deployed.changed) return []
113
- const d = deployed.describe
114
- const models = Object.keys(d.models).length
115
- const count = (n: number, what: string) => `${n} ${what}${n === 1 ? "" : "s"}`
116
- const keys = Object.keys(env).sort()
117
- log(`server: ${count(d.endpoints.length, "endpoint")}, ${count(d.channels.length, "channel")}${models ? `, ${count(models, "model")} (database in ${relative(root, dbDir).replace(/\\/g, "/")})` : ""}${keys.length ? `, env ${keys.join(", ")} (.env)` : ""}`)
118
- return deployed.notes.map((n) => `server: ${n}`)
119
- },
189
+ run: (bundle) => runBundle(bundle).finally(() => { ran = true; firstRan() }),
120
190
  handle: async (req, route, ip) => {
121
191
  if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS })
122
192
  if (route.startsWith("/files/") && (req.method === "GET" || req.method === "HEAD")) {
123
193
  return fileResponse(filesDir, route.slice("/files/".length), req, { "access-control-allow-origin": "*" })
124
194
  }
195
+ // the admin routes: the other commands of this machine (devLink.ts)
196
+ if (route === "/status" || route === "/reset") {
197
+ if (!isLoopback(ip)) return envelope({ ok: false, status: 403, message: "from this machine only" })
198
+ if (route === "/status" && req.method === "GET") return envelope({ ok: true, root, running: running !== null })
199
+ if (route === "/reset" && req.method === "POST") {
200
+ await reset()
201
+ log("server: the database was emptied (a run asked for a fresh one: render --fresh, call --reset)")
202
+ return envelope({ ok: true })
203
+ }
204
+ }
125
205
  const id = route.startsWith("/api/") ? decodeURIComponent(route.slice("/api/".length)) : ""
126
206
  if (!id || req.method !== "POST") return envelope({ ok: false, status: 404, message: "not an endpoint call" })
207
+ const caller = ipOf(req, ip)
208
+ if (!calls.take(`${callerKey(bearerOf(req), caller)}|${id}`)) return envelope(TOO_MANY_CALLS)
209
+ await known()
127
210
  if (!backend) return envelope({ ok: false, status: 404, message: "The project has no server files" })
128
211
  let args: unknown
129
212
  let uploads: Upload[] = []
@@ -143,25 +226,24 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
143
226
  if (!Array.isArray(args)) return envelope({ ok: false, status: 400, message: "body must be {\"args\":[…]}" })
144
227
  // a stored file's url starts where this very request found the backend (a phone's address of this machine, a tunnel)
145
228
  const url = new URL(req.url)
146
- const filesBase = url.origin + url.pathname.slice(0, url.pathname.length - route.length)
147
- return envelope(await backend.invoke(id, args, { headers: Object.fromEntries(req.headers), ip, sessionToken: bearerOf(req) ?? undefined, uploads, filesBase }))
229
+ const filesBase = originOf(req, ip) + url.pathname.slice(0, url.pathname.length - route.length)
230
+ return envelope(await backend.invoke(id, args, { headers: Object.fromEntries(req.headers), ip: caller, sessionToken: bearerOf(req) ?? undefined, uploads, filesBase }))
148
231
  },
149
232
  socket: (req, ip) => (conn) => {
150
- const c = hub.connect(conn, { ip, session: bearerOf(req) })
233
+ // a stored file's url, in what a computed answers: where this socket found the backend
234
+ const url = new URL(req.url)
235
+ const c = hub.connect(conn, { ip: ipOf(req, ip), session: bearerOf(req), filesBase: originOf(req, ip) + url.pathname.replace(/\/ws$/, "") })
151
236
  sockets.add(conn)
152
237
  return {
153
238
  onMessage: (raw) => { void c.message(raw) },
154
239
  onClose: () => { sockets.delete(conn); c.close() },
155
240
  }
156
241
  },
157
- reset: async () => {
158
- if (!backend || running === null) return
159
- await backend.close()
160
- rmSync(dbDir, { recursive: true, force: true })
161
- rmSync(filesDir, { recursive: true, force: true })
162
- backend = start()
163
- await backend.deploy(running, readAppEnv(root))
242
+ setClock: (offsetMs) => {
243
+ clock = offsetMs
244
+ backend?.setClock(offsetMs)
164
245
  },
246
+ reset,
165
247
  close: async () => {
166
248
  for (const conn of [...sockets]) conn.close()
167
249
  await backend?.close()
@@ -170,15 +252,109 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
170
252
  }
171
253
  }
172
254
 
255
+ // ───────────────────────── the backend another dev server of this machine runs ─────────────────────────
256
+
257
+ /**
258
+ * The backend of a dev server whose project's backend ANOTHER dev server of this machine runs (a `web dev`
259
+ * started beside a `lecodes dev` — devLink.ts finds it): its routes are forwarded there — calls, stored files,
260
+ * the channel socket — with the origin and the address the device used (`x-forwarded-*`, believed from this
261
+ * machine only), so what the owner answers (a stored file's url) is one the device reaches. Nothing runs
262
+ * here: the owner compiles and runs the server files itself, on every save.
263
+ */
264
+ export const forwardedBackend = (target: string, log: (msg: string) => void): ProjectBackend<true> => {
265
+ const sockets = new Set<Socket>()
266
+ let silent = false
267
+ const unreachable = (e: unknown) => {
268
+ if (!silent) log(`[server:error] this project's backend runs in another dev server (${target}), which does not answer: ${(e as Error)?.message ?? e} — start it again, or restart this one to run the backend here`)
269
+ silent = true
270
+ }
271
+ const forwarded = (req: Request, ip: string | undefined): Record<string, string> => {
272
+ const url = new URL(req.url)
273
+ return {
274
+ "x-forwarded-host": req.headers.get("host") ?? url.host,
275
+ "x-forwarded-proto": url.protocol === "https:" ? "https" : "http",
276
+ ...(ip ? { "x-forwarded-for": ip } : {}),
277
+ }
278
+ }
279
+ return {
280
+ compile: async (entries) => (hasServerFiles(entries) ? true : null),
281
+ run: async () => [],
282
+ handle: async (req, route, ip) => {
283
+ // the owner answers its admin routes to this machine — and so does this one, never on a device's behalf
284
+ if ((route === "/status" || route === "/reset") && !isLoopback(ip)) return new Response(JSON.stringify({ ok: false, status: 403, message: "from this machine only" }), { status: 200, headers: { "content-type": "application/json", ...CORS } })
285
+ const headers = new Headers(req.headers)
286
+ for (const h of ["host", "connection", "content-length", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-for"]) headers.delete(h)
287
+ for (const [k, v] of Object.entries(forwarded(req, ip))) headers.set(k, v)
288
+ const bodyless = req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS"
289
+ try {
290
+ const res = await fetch(target + route + new URL(req.url).search, {
291
+ method: req.method, headers, redirect: "manual",
292
+ ...(bodyless ? {} : { body: req.body, duplex: "half" }),
293
+ } as RequestInit)
294
+ silent = false
295
+ // the body is passed on as it came (fetch already undid any transfer coding)
296
+ const out = new Headers(res.headers)
297
+ for (const h of ["content-encoding", "content-length", "transfer-encoding", "connection"]) out.delete(h)
298
+ return new Response(res.body, { status: res.status, headers: out })
299
+ } catch (e) {
300
+ unreachable(e)
301
+ return new Response(JSON.stringify({ ok: false, status: 503, message: "The project's backend (another lecodes dev on this machine) does not answer" }), { status: 200, headers: { "content-type": "application/json", ...CORS } })
302
+ }
303
+ },
304
+ socket: (req, ip) => (conn) => {
305
+ // a client socket is Bun's or Node's (22 on): an older Node has none
306
+ if (typeof WebSocket === "undefined") {
307
+ unreachable(new Error("forwarding the channel socket needs Bun or Node 22+ — run this server's backend here (stop the other dev server) or update Node"))
308
+ conn.close()
309
+ return { onMessage: () => {}, onClose: () => {} }
310
+ }
311
+ const auth = req.headers.get("authorization")
312
+ // Bun's and Node's WebSocket take headers beside the protocols (not the browser's)
313
+ const Ws = WebSocket as unknown as new (url: string, init: { headers: Record<string, string> }) => WebSocket
314
+ const up = new Ws(target.replace(/^http/, "ws") + "/ws", { headers: { ...forwarded(req, ip), ...(auth ? { authorization: auth } : {}) } })
315
+ const queue: string[] = []
316
+ let open = false
317
+ let ended = false
318
+ const end = () => {
319
+ if (ended) return
320
+ ended = true
321
+ sockets.delete(conn)
322
+ try { up.close() } catch { /* not open yet */ }
323
+ conn.close()
324
+ }
325
+ up.addEventListener("open", () => { open = true; silent = false; for (const m of queue.splice(0)) up.send(m) })
326
+ up.addEventListener("message", (ev) => { conn.send(typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data as ArrayBuffer)) })
327
+ up.addEventListener("close", end)
328
+ up.addEventListener("error", (e) => { if (!open) unreachable(new Error("the socket did not open")); void e; end() })
329
+ sockets.add(conn)
330
+ return {
331
+ onMessage: (raw) => { if (open) up.send(raw); else queue.push(raw) },
332
+ onClose: () => { ended = true; sockets.delete(conn); try { up.close() } catch { /* already going */ } },
333
+ }
334
+ },
335
+ // the owner's database and clock: never this server's to change
336
+ reset: async () => {},
337
+ setClock: () => {},
338
+ close: async () => { for (const conn of [...sockets]) conn.close() },
339
+ }
340
+ }
341
+
173
342
  // ───────────────────────────── a backend for one run of a bundle ─────────────────────────────
174
343
 
175
344
  export type RunBackend = {
176
345
  /** Called by the compile, once it has the project's entries: the backend is started when there
177
346
  * are server files, and its url — what the app's stubs call — is the answer. */
178
347
  start: (entries: CompileEntry[], onWarning: (w: string) => void) => Promise<string | undefined>
179
- /** Start again on an empty database. */
348
+ /** Start again on an empty database, on the machine's clock. */
180
349
  reset: () => Promise<void>
350
+ /** The server's clock reads `offsetMs` ahead of the machine's — a test's case clock (`lecodes test`: a flow's
351
+ * `clock` and `wait` steps move the server with the apps). */
352
+ setClock: (offsetMs: number) => void
181
353
  close: () => Promise<void>
354
+ /** Set when this run talks to a running `lecodes dev` of the project (devLink.ts `devRunBackend`): its backend
355
+ * is the one the developer's devices use, on the machine's clock — a run on it keeps real time
356
+ * (`setClock` refuses) and never empties it unasked. */
357
+ dev?: { port: number, url: string }
182
358
  }
183
359
 
184
360
  /** A port that stays the same for a project from run to run: the app keeps its session under the
@@ -194,10 +370,11 @@ const stablePort = (root: string): number => {
194
370
  * 127.0.0.1. `test`: a database of its own (`.lecodes/test-db`, emptied by `reset`) and any free
195
371
  * port — a test never touches what `lecodes dev` and `desktop run` keep in `.lecodes/db`.
196
372
  */
197
- export const runBackend = (options: { root: string, command: string, log: (msg: string) => void, test?: boolean }): RunBackend => {
373
+ export const runBackend = (options: { root: string, command: string, log: (msg: string) => void, test?: boolean, keepTestDb?: boolean }): RunBackend => {
198
374
  const { root } = options
199
375
  const dbDir = join(root, LECODES_DIR, options.test ? "test-db" : "db")
200
- if (options.test) { rmSync(dbDir, { recursive: true, force: true }); rmSync(filesDirOf(dbDir), { recursive: true, force: true }) }
376
+ // `keepTestDb`: the test database as the last run left it (`lecodes call`; `lecodes test` empties it)
377
+ if (options.test && !options.keepTestDb) { rmSync(dbDir, { recursive: true, force: true }); rmSync(filesDirOf(dbDir), { recursive: true, force: true }) }
201
378
  const backend = createProjectBackend({ root, dbDir, log: options.log, command: options.command })
202
379
  let server: Served | null = null
203
380
 
@@ -222,6 +399,7 @@ export const runBackend = (options: { root: string, command: string, log: (msg:
222
399
  return `http://127.0.0.1:${server.port}/server`
223
400
  },
224
401
  reset: () => backend.reset(),
402
+ setClock: (offsetMs) => backend.setClock(offsetMs),
225
403
  close: async () => {
226
404
  server?.close()
227
405
  server = null
@@ -1,5 +1,5 @@
1
1
  import { execFileSync, spawn, spawnSync } from "node:child_process"
2
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, linkSync } from "node:fs"
2
+ import { constants as fsConstants, 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
5
  import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, macPlatformKey, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
@@ -573,8 +573,14 @@ const stageBundle = (js: string, resources: { name: string, absPath?: string }[]
573
573
  const st = statSync(r.absPath)
574
574
  if (!st.isFile()) continue
575
575
  const dst = join(runDir, r.name)
576
+ // NEVER ONTO AN EXISTING FILE: what is already there may be a hard link to another project file, and a copy
577
+ // over it rewrites that file IN THE PROJECT (a case-only name clash on Windows did that to a GLB, 2026-11-04).
578
+ // The copy is the fallback for a link across volumes, and it refuses to replace anything.
576
579
  try { linkSync(r.absPath, dst) }
577
- catch { copyFileSync(r.absPath, dst); copied++ }
580
+ catch (e) {
581
+ if ((e as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`two resources stage as ${r.name} (names that differ only in case?): ${r.absPath}`)
582
+ copyFileSync(r.absPath, dst, fsConstants.COPYFILE_EXCL); copied++
583
+ }
578
584
  files++
579
585
  bytes += st.size
580
586
  }
@@ -150,6 +150,7 @@ export const startLiteSession = async (options: { root: string, manifest: Manife
150
150
  const server: DevServer = await startDevServer({
151
151
  root: options.root, manifest: options.manifest, name: options.name, lite: options.lite, web: options.web,
152
152
  origin: `http://127.0.0.1:${port}`, bind: "127.0.0.1", port, token: "test", entryOverride: options.entry, dbDir,
153
+ ambiguousEntry: "error",
153
154
  })
154
155
  const compiled = await server.recompile()
155
156
  if (!compiled.ok) { server.close(); throw new CliError(`Compile failed:\n${compiled.error}`) }
@@ -52,8 +52,10 @@ export const findPlugins = (path: string): PluginDir[] => {
52
52
  .filter(p => statSync(p.dir).isDirectory() && isPluginDir(p.dir))
53
53
  }
54
54
 
55
- /** The CLI carries no TypeScript: a contract is read with the one its author has — the plugin's own
56
- * (looked up from its directory upwards), else whatever the CLI's install can see. */
55
+ /** A contract is read with the `typescript` package (5.x: 6 is ESM-only and `require` gets a stub
56
+ * without `createSourceFile`): the plugin's own (looked up from its directory upwards), else the
57
+ * one next to the CLI's install — a dependency of lecodes-cli after 2.4.9, so a bun / npm install
58
+ * of the CLI brings it; the standalone binaries carry none and read the plugin's. */
57
59
  export const loadTypescript = (dir: string): typeof tsModule => {
58
60
  for (const from of [join(dir, "package.json"), import.meta.url]) {
59
61
  try {
@@ -62,7 +64,7 @@ export const loadTypescript = (dir: string): typeof tsModule => {
62
64
  if (ts?.createSourceFile) return ts
63
65
  } catch { /* the next place */ }
64
66
  }
65
- throw new CliError(`Reading a contract needs TypeScript — add it to the plugin's repo: npm install --save-dev typescript`)
67
+ throw new CliError(`Reading a contract needs the typescript package 5.x — next to the CLI (bun add -g "typescript@^5.8.3" for a bun-installed lecodes-cli) or in the plugin's repo (npm install --save-dev "typescript@^5.8.3"); TypeScript 6 is not read`)
66
68
  }
67
69
 
68
70
  export type Generated = { file: string, text: string }