lecodes-cli 2.4.9 → 2.5.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 (57) hide show
  1. package/dist/index.js +2681 -1196
  2. package/package.json +10 -10
  3. package/runtime/materials/decal-relief.mat +21 -46
  4. package/runtime/materials/decal.mat +41 -61
  5. package/runtime/materials/foliage-cards.mat +28 -140
  6. package/runtime/materials/foliage-impostor.mat +485 -0
  7. package/runtime/materials/foliage.mat +232 -0
  8. package/runtime/materials/grass.mat +244 -0
  9. package/runtime/materials/particles-quad.mat +5 -3
  10. package/runtime/materials/particles.mat +5 -3
  11. package/runtime/sdk-types.json +1 -1
  12. package/runtime/web/VERSION.json +3 -3
  13. package/runtime/web/assets/materials-Cs_z1zLi.bin +0 -0
  14. package/runtime/web/assets/materials-world-BtoAfznA.bin +0 -0
  15. package/runtime/web/player.js +3 -3
  16. package/runtime/web-lite/assets/{canvas-WxiusXCB.js → canvas-wFjS40IX.js} +1 -1
  17. package/runtime/web-lite/assets/creator2d-1pSkYRCn.js +1 -0
  18. package/runtime/web-lite/assets/creator2d-Ci5-pZGr.wasm +0 -0
  19. package/runtime/web-lite/assets/{pager-DpGa2uaE.js → pager-BZAO9gMC.js} +1 -1
  20. package/runtime/web-lite/assets/{plugins-CuEPHbHL.js → plugins-B7TuO_p4.js} +1 -1
  21. package/runtime/web-lite/assets/scene2d-F6T1Io7I.js +1 -0
  22. package/runtime/web-lite/assets/{tween-BnWYhJ7P.js → tween-DbrQTld4.js} +1 -1
  23. package/runtime/web-lite/assets/{video-BLj61nDx.js → video-bzR_t2i6.js} +1 -1
  24. package/runtime/web-lite/assets/{vlist-CwSYpLz-.js → vlist-Ap-F4H5J.js} +1 -1
  25. package/runtime/web-lite/features.json +18 -6
  26. package/runtime/web-lite/lite.js +6 -6
  27. package/src/commands/compile.ts +2 -1
  28. package/src/commands/hub/create.ts +100 -0
  29. package/src/commands/hub/credential.ts +32 -0
  30. package/src/commands/hub/index.ts +21 -0
  31. package/src/commands/hub/login.ts +89 -0
  32. package/src/commands/hub/status.ts +109 -0
  33. package/src/commands/index.ts +5 -1
  34. package/src/commands/level.ts +123 -0
  35. package/src/commands/lightmap.ts +41 -76
  36. package/src/commands/navmesh.ts +84 -67
  37. package/src/commands/secrets.ts +147 -0
  38. package/src/commands/update/index.ts +154 -64
  39. package/src/compile/designCompile.ts +2 -0
  40. package/src/compile/headlessBundle.ts +2 -0
  41. package/src/compile/projectCompile.ts +12 -1
  42. package/src/compile/sceneCompile.ts +2 -1
  43. package/src/dev/backendWorker.ts +6 -1
  44. package/src/dev/devServer.ts +2 -1
  45. package/src/dev/localBackend.ts +21 -14
  46. package/src/dev/projectBackend.ts +7 -3
  47. package/src/dev/watchProject.ts +2 -0
  48. package/src/hosts/desktopRenderer.ts +10 -2
  49. package/src/hosts/peerInstall.ts +26 -6
  50. package/src/hub/api.ts +220 -0
  51. package/src/hub/config.ts +53 -0
  52. package/src/hub/git.ts +148 -0
  53. package/src/platform/api.ts +21 -0
  54. package/src/project/env.ts +29 -0
  55. package/src/web/build.ts +149 -88
  56. package/runtime/web/assets/materials-CJzxzdUK.bin +0 -0
  57. package/runtime/web/assets/materials-world-BNQb0Z1l.bin +0 -0
@@ -1,11 +1,11 @@
1
1
  import { spawnSync } from "node:child_process"
2
- import { existsSync, readdirSync } from "node:fs"
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
3
3
  import { homedir } from "node:os"
4
4
  import { dirname, join, resolve } from "node:path"
5
5
  import pkg from "../../../package.json"
6
6
  import { CliError, bool, c, defineCommand, log, note, readFlags, type Command } from "../../cli"
7
7
  import { OPTIONAL_PEERS, peerSource, peerVersion } from "../../hosts/peers"
8
- import { installPeers } from "../../hosts/peerInstall"
8
+ import { installPeers, peerSpec } from "../../hosts/peerInstall"
9
9
  import { archiveSpec, archivedVersion } from "../../hosts/archives"
10
10
  import { commandLine, installGlobalArgv, ownManager } from "../../hosts/packageManager"
11
11
  import { updateDesktop } from "../../hosts/desktopRenderer"
@@ -18,18 +18,24 @@ import app from "../app"
18
18
  * `lecodes update` — one command that moves EVERYTHING a project depends on to its newest published
19
19
  * state, in the order the pieces depend on each other:
20
20
  *
21
- * 1. desktop the LeCodes Desktop host `desktop run` / `render --desktop` use (= `desktop update`)
22
- * 2. matc Filament's material compiler for local shader compiles (= `shaders update`)
23
- * 3. shaders recompile the project's .mat files with that matc (= `shaders compile`)
24
- * 4. types the IDE types (.lecodes/types + tsconfig) from the server (types.ts)
25
- * 5. shells ios/ + android/ pins → newest SDK release + plugin tags, re-sync (= `app update`)
26
- * 6. cli the npm installs — every installed optional peer (lecodes-headless, lecodes-design,
27
- * lecodes-3d-editor, lecodes-assets, @napi-rs/canvas) and the CLI itself that is behind:
28
- * peers in ~/.lecodes/peers (first-use installs, peerInstall.ts) are re-installed there
29
- * — the renderer at THIS CLI's version, an editor at the newest one out that this
30
- * CLI's peer range takes, the rest at npm's latest in range; global ones
31
- * (`npm install -g`, the old way) via `npm install -g …@latest`; the CLI itself last
32
- * (the standalone binary can't come from npm — it says so and does the peers only)
21
+ * 1. cli the CLI itself, FIRST: when npm has a newer one it is installed and the rest of the run
22
+ * is ITS — the same command from the same entry file, told it was handed over. Every step
23
+ * below is then the new version's, the peers above all (updated last, the old CLI put back
24
+ * its own ranges: lecodes-assets 0.2.1 under CLI 2.4.10, and a second `update` was needed —
25
+ * 2026-10-28). Already current (a `bun add -g lecodes-cli` beforehand) → nothing to do.
26
+ * The standalone binary can't come from npm: it says so and goes on.
27
+ * 2. desktop the LeCodes Desktop host `desktop run` / `render --desktop` use (= `desktop update`)
28
+ * 3. matc Filament's material compiler for local shader compiles (= `shaders update`)
29
+ * 4. shaders recompile the project's .mat files with that matc (= `shaders compile`)
30
+ * 5. types the IDE types (.lecodes/types + tsconfig) from the server (types.ts)
31
+ * 6. shells ios/ + android/ pins → newest SDK release + plugin tags, re-sync (= `app update`)
32
+ * 7. peers every installed optional peer (lecodes-headless, lecodes-design, lecodes-3d-editor,
33
+ * lecodes-assets, @napi-rs/canvas, …) that is behind WHAT AN INSTALL WOULD PUT THERE:
34
+ * in ~/.lecodes/peers (first-use installs, peerInstall.ts) that is peerInstall's own spec
35
+ * — the renderer at THIS CLI's version, an editor at the newest archive its range takes,
36
+ * an npm peer at the newest version IN ITS RANGE (not npm's latest: compared with latest,
37
+ * canvas and marcidb-embedded read "behind" on every run and were re-installed at the
38
+ * version they had); global ones (`npm install -g`, the old way) via `…@latest`
33
39
  *
34
40
  * Every step is the existing subcommand, so nothing here can drift from what the individual
35
41
  * commands do; this only sequences them and keeps going when one fails (the summary says which).
@@ -86,6 +92,45 @@ const semverNewer = (a: string, b: string): number => {
86
92
  return 0
87
93
  }
88
94
 
95
+ /** A version test for the range forms peer specs take (^x.y.z, ~x.y.z, >=x.y.z, x.y.z, *, latest); null for
96
+ * any other form — the caller falls back to npm's latest. */
97
+ const rangeTest = (range: string): ((v: string) => boolean) | null => {
98
+ const r = range.trim()
99
+ if (r === "" || r === "*" || r === "latest") return () => true
100
+ const m = /^(\^|~|>=)?(\d+)\.(\d+)\.(\d+)$/.exec(r)
101
+ if (!m) return null
102
+ const [ a, b, p ] = [ Number(m[2]), Number(m[3]), Number(m[4]) ]
103
+ const low = `${a}.${b}.${p}`
104
+ if (!m[1]) return (v) => v === low
105
+ if (m[1] === ">=") return (v) => semverNewer(v, low) >= 0
106
+ const below = m[1] === "~" ? `${a}.${b + 1}.0` : a > 0 ? `${a + 1}.0.0` : b > 0 ? `0.${b + 1}.0` : `0.0.${p + 1}`
107
+ return (v) => semverNewer(v, low) >= 0 && semverNewer(v, below) < 0
108
+ }
109
+
110
+ /** The newest release of an npm package inside a range — what an install of `name@range` resolves to; null
111
+ * when npm is unreachable or the range is of another form. */
112
+ const npmNewestIn = async (name: string, range: string): Promise<string | null> => {
113
+ const accepts = rangeTest(range)
114
+ if (!accepts) return null
115
+ try {
116
+ const res = await fetch(`${NPM}/${name.replace("/", "%2f")}`, { headers: { accept: "application/vnd.npm.install-v1+json" }, signal: AbortSignal.timeout(6000) })
117
+ if (!res.ok) return null
118
+ const json = await res.json() as { versions?: Record<string, unknown> }
119
+ return Object.keys(json.versions ?? {}).filter((v) => /^\d+\.\d+\.\d+$/.test(v) && accepts(v)).sort(semverNewer).at(-1) ?? null
120
+ } catch {
121
+ return null
122
+ }
123
+ }
124
+
125
+ /** The version of the lecodes-cli package an entry file belongs to (`<pkg>/dist/index.js`), or null. */
126
+ const cliVersionAt = (entry: string): string | null => {
127
+ try {
128
+ return (JSON.parse(readFileSync(join(dirname(entry), "..", "package.json"), "utf8")) as { name?: string, version?: string }).version ?? null
129
+ } catch {
130
+ return null
131
+ }
132
+ }
133
+
89
134
  /** Running straight from the repo checkout (src/commands/update/index.ts), not a built dist/binary. */
90
135
  const FROM_SOURCE = /[\\/]src[\\/]commands[\\/]/.test(import.meta.url)
91
136
  /** The standalone release build: the bundle + chisel sit next to the executable (same test as index.ts). */
@@ -95,15 +140,16 @@ const STANDALONE = existsSync(join(dirname(process.execPath), "runtime")) &&
95
140
  export default defineCommand({
96
141
  name: "update",
97
142
  summary: "Move everything the project depends on to its newest published state",
98
- description: "In dependency order: the desktop host (`desktop update`), matc (`shaders update`), the project's shader compiles, the IDE types (.lecodes/types/ + tsconfig.json, from the server — the former `lecodes types`), the ios/android shell pins (`app update`), then the GLOBAL installs: every installed optional peer (lecodes-headless, lecodes-design, lecodes-3d-editor, lecodes-assets) and this CLI that is behind npm's latest is installed again, with the package manager the CLI came by (`npm install -g`, or `bun add -g` for a CLI installed with bun).\n\nPinned tools are skipped, not overridden: LECODES_DESKTOP_EXE / LECODES_MATC mean a local build. Keeps going when a step fails; the summary says which.",
143
+ description: "In dependency order: this CLI first (when npm has a newer one it is installed with the package manager the CLI came by — `npm install -g`, or `bun add -g` for a CLI installed with bun — and the rest of the run is the new version's), the desktop host (`desktop update`), matc (`shaders update`), the project's shader compiles, the IDE types (.lecodes/types/ + tsconfig.json, from the server — the former `lecodes types`), the ios/android shell pins (`app update`), then every installed optional peer (lecodes-headless, lecodes-design, lecodes-3d-editor, lecodes-assets, …) that is behind what this CLI installs: its own version for the renderer, the newest version in its range for the rest.\n\nPinned tools are skipped, not overridden: LECODES_DESKTOP_EXE / LECODES_MATC mean a local build. Keeps going when a step fails; the summary says which.",
99
144
  flags: {
100
145
  local: bool("types from the SDK bundled in this CLI"),
101
146
  "no-shells": bool("leave ios/android pins alone (no re-sync)"),
102
- check: bool("npm step: report what's behind, install nothing"),
103
- "no-cli": bool("skip the npm step (offline)"),
147
+ check: bool("npm steps: report what's behind, install nothing"),
148
+ "no-cli": bool("skip the npm steps — the CLI and its peers (offline)"),
149
+ yes: bool("answer yes to every install prompt (the global --yes / -y; LECODES_YES=1)"),
104
150
  },
105
- examples: ["lecodes update", "lecodes update --check", "lecodes update --local --no-shells"],
106
- run: async ({ flags }) => {
151
+ examples: ["lecodes update", "lecodes update --check", "lecodes update --yes", "lecodes update --local --no-shells"],
152
+ run: async ({ flags, argv }) => {
107
153
  const steps: Step[] = []
108
154
  const heading = (name: string, what: string) => log(`\n${c.bold(name)} ${c.dim(what)}`)
109
155
  const run = async (name: string, what: string, fn: () => Promise<Outcome | [Outcome, string]>) => {
@@ -122,35 +168,82 @@ export default defineCommand({
122
168
 
123
169
  const root = findProjectDir(process.cwd())
124
170
 
125
- // 1. desktop host
171
+ // 1. this CLI, first (see the header). LECODES_UPDATE_RESUMED is the hand-over: the CLI before this one
172
+ // installed it and ran this command again — "<from> → <to>", for the summary.
173
+ const resumed = process.env.LECODES_UPDATE_RESUMED
174
+ let handover = null as string | null // set inside the step's closure, read after it
175
+ if (resumed) {
176
+ delete process.env.LECODES_UPDATE_RESUMED
177
+ heading("cli", "lecodes-cli itself")
178
+ log(` lecodes-cli ${resumed}`)
179
+ steps.push({ name: "cli", outcome: "updated", detail: `lecodes-cli ${resumed}` })
180
+ } else {
181
+ await run("cli", "lecodes-cli itself — first, so every step after it is the new version's", async () => {
182
+ if (flags["no-cli"]) return ["skipped", "--no-cli"]
183
+ if (FROM_SOURCE) return ["skipped", "running from the repo checkout — the CLI and its peers are the repo's own"]
184
+ const latest = await npmLatest("lecodes-cli")
185
+ if (!latest) return ["skipped", "npm unreachable"]
186
+ if (semverNewer(latest, pkg.version) <= 0) { log(` lecodes-cli ${pkg.version} ${c.dim("(up to date)")}`); return "ok" }
187
+ log(` lecodes-cli ${pkg.version} ${c.yellow("→ " + latest)}`)
188
+ if (flags.check) return ["updated", `${pkg.version} → ${latest} behind — run: lecodes update (the peers below are compared with this CLI's ranges, not ${latest}'s)`]
189
+ if (STANDALONE) return ["skipped", `this is the standalone build — ${latest} comes as the release archive, not through npm`]
190
+ // with the manager this CLI was installed by: another one's global dir is another copy
191
+ const tool = ownManager()
192
+ const install = installGlobalArgv(tool, [`lecodes-cli@${latest}`])
193
+ log(" " + c.dim("$") + " " + commandLine(tool, install))
194
+ const r = spawnSync(tool, install, { stdio: "inherit", shell: true })
195
+ if (r.error) throw new CliError(tool + " could not run: " + r.error.message)
196
+ if (r.status !== 0) throw new CliError(commandLine(tool, install.slice(0, 2)) + " exited with " + (r.status ?? "a signal"))
197
+ // The new CLI is the same entry file now — unless the install landed in another copy than the one
198
+ // running (another prefix, another manager): then the rest stays this version's, and says so.
199
+ const now = cliVersionAt(process.argv[1] ?? "")
200
+ if (now !== latest) {
201
+ note(`the install put ${latest} where this CLI does not run from (${process.argv[1]} is ${now ?? "unreadable"}) — the rest of this run is ${pkg.version}'s; run \`lecodes update\` again`)
202
+ return ["updated", `${pkg.version} → ${latest} (another copy — run lecodes update again)`]
203
+ }
204
+ handover = `${pkg.version} → ${latest}`
205
+ return ["updated", handover]
206
+ })
207
+ }
208
+ if (handover) {
209
+ // the rest of the run is the new version's: the same command, the same flags, from the same entry file —
210
+ // never `lecodes` off PATH, which may name another install
211
+ log(c.dim(`\n the rest of this update runs in lecodes-cli ${handover.split(" → ")[1]}`))
212
+ const r = spawnSync(process.execPath, [process.argv[1]!, "update", ...argv], { stdio: "inherit", env: { ...process.env, LECODES_UPDATE_RESUMED: handover } })
213
+ if (r.error || r.status === null) throw new CliError(`the new lecodes-cli could not run: ${r.error?.message ?? "a signal"} — run \`lecodes update\` again`)
214
+ process.exitCode = r.status
215
+ return
216
+ }
217
+
218
+ // 2. desktop host
126
219
  await run("desktop", "LeCodes Desktop host (desktop run / render --desktop)", async () => {
127
220
  if (process.env.LECODES_DESKTOP_EXE) return ["skipped", `LECODES_DESKTOP_EXE=${process.env.LECODES_DESKTOP_EXE} (a local build; \`lecodes desktop update\` refreshes the cache anyway)`]
128
221
  await updateDesktop()
129
222
  return "ok"
130
223
  })
131
224
 
132
- // 2. matc
225
+ // 3. matc
133
226
  await run("matc", "Filament material compiler for local shader compiles", async () => {
134
227
  if (process.env.LECODES_MATC) return ["skipped", `LECODES_MATC=${process.env.LECODES_MATC} (a local matc)`]
135
228
  await updateMatc()
136
229
  return "ok"
137
230
  })
138
231
 
139
- // 3. shaders (project)
232
+ // 4. shaders (project)
140
233
  await run("shaders", "recompile the project's .mat shaders with the current matc", async () => {
141
234
  if (!root) return ["skipped", "not inside a project"]
142
235
  await compileShaders()
143
236
  return "ok"
144
237
  })
145
238
 
146
- // 4. types (project)
239
+ // 5. types (project)
147
240
  await run("types", flags.local ? "IDE types from the SDK bundled in this CLI" : "IDE types from the server", async () => {
148
241
  if (!root) return ["skipped", "not inside a project"]
149
242
  await refreshTypes({ local: flags.local, check: false })
150
243
  return "ok"
151
244
  })
152
245
 
153
- // 5. native shells (project)
246
+ // 6. native shells (project)
154
247
  await run("shells", "ios/ + android/ pins → newest SDK release + plugin tags, then re-sync", async () => {
155
248
  if (!root) return ["skipped", "not inside a project"]
156
249
  if (flags["no-shells"]) return ["skipped", "--no-shells"]
@@ -160,37 +253,42 @@ export default defineCommand({
160
253
  return "ok"
161
254
  })
162
255
 
163
- // 6. this CLI + its optional peers — the GLOBAL installs (`npm install -g` or `bun install -g`,
164
- // whichever this CLI came by: hosts/packageManager.ts): every installed one that is behind npm's latest moves forward. The peers go
165
- // first, the CLI itself last — replacing the running CLI's files is fine (this process already
166
- // holds its code), but its new version is what the next invocation runs, so nothing after it here.
167
- // Peers that are NOT installed stay that way: they're optional, installing one is a choice.
168
- await run("cli", "lecodes-cli + its optional peers (global npm installs)", async () => {
256
+ // 7. the optional peers — the CLI's prefix (~/.lecodes/peers, filled on first use) or global installs.
257
+ // Each installed one is compared with what an install would PUT where it lives, never with npm's latest
258
+ // alone: in the prefix that is peerInstall's own spec (the renderer at THIS CLI's version — compiler and
259
+ // headless host are one ABI set —, an editor's archive, an npm peer's newest release in its range), so the
260
+ // report and the install cannot disagree; a global install (`npm install -g`, the old way) moves to npm's
261
+ // latest, as it was put there. Peers that are NOT installed stay that way: optional, installing one is a choice.
262
+ await run("peers", "the CLI's optional peers (~/.lecodes/peers, or global installs)", async () => {
169
263
  if (flags["no-cli"]) return ["skipped", "--no-cli"]
170
264
  if (FROM_SOURCE) return ["skipped", "running from the repo checkout — the CLI and its peers are the repo's own"]
171
- // Where each peer lives decides how it moves: the CLI's own prefix (~/.lecodes/peers, filled on
172
- // first use) is re-installed by peerInstall — the renderer there tracks THIS CLI's version, not
173
- // npm's latest, because compiler and headless host are one ABI set; a global install
174
- // (`npm install -g`, the old way) moves through npm like before.
175
- const rows: { name: string, installed: string | null, latest: string | null, where: "global" | "prefix" | null }[] = []
265
+ type Row = { name: string, installed: string | null, target: string | null, latest: string | null, range: string | null, where: "global" | "prefix" | null }
266
+ const rows: Row[] = []
176
267
  for (const [name, probe] of OPTIONAL_PEERS) {
177
268
  const where = peerSource(name, probe)
178
269
  const installed = peerVersion(name, probe)
179
- // an archived peer (the headless host, the editors) has no "latest" of a registry: the
180
- // version this CLI installs is the one to have
181
- const latest = !installed ? null
182
- : await archivedVersion(name) ?? await npmLatest(name)
183
- rows.push({ name, installed, latest, where })
270
+ if (!installed) { rows.push({ name, installed, target: null, latest: null, range: null, where }); continue }
271
+ // an archived peer (the headless host, the editors) has no "latest" of a registry: the version this CLI
272
+ // installs is the one to have
273
+ const archived = await archivedVersion(name)
274
+ if (archived) { rows.push({ name, installed, target: archived, latest: archived, range: null, where }); continue }
275
+ const latest = await npmLatest(name)
276
+ if (where !== "prefix") { rows.push({ name, installed, target: latest, latest, range: null, where }); continue }
277
+ const spec = await peerSpec(name) // `name@range` — what installPeers below would install
278
+ const range = spec.lastIndexOf("@") > 0 ? spec.slice(spec.lastIndexOf("@") + 1) : "latest"
279
+ rows.push({ name, installed, target: await npmNewestIn(name, range) ?? latest, latest, range, where })
184
280
  }
185
- rows.push({ name: "lecodes-cli", installed: pkg.version, latest: await npmLatest("lecodes-cli"), where: "global" })
186
- if (rows.every((r) => r.latest === null)) return ["skipped", "npm unreachable"]
187
- const behind = rows.filter((r) => r.installed && r.latest && semverNewer(r.latest, r.installed) > 0)
281
+ const known = rows.filter((r) => r.installed)
282
+ if (known.length > 0 && known.every((r) => r.target === null)) return ["skipped", "npm unreachable"]
283
+ const behind = known.filter((r) => r.target && semverNewer(r.target, r.installed!) > 0)
188
284
  for (const r of rows) {
189
285
  const state = !r.installed ? c.dim("not installed (optional — installed on first use)")
190
- : !r.latest ? c.dim("(npm: unknown)")
191
- : behind.includes(r) ? c.yellow("→ " + r.latest)
286
+ : !r.target ? c.dim("(npm: unknown)")
287
+ : behind.includes(r) ? c.yellow("→ " + r.target)
192
288
  : c.dim("(up to date)")
193
- log(" " + r.name.padEnd(18) + " " + (r.installed ?? "").padEnd(8) + " " + (r.where === "prefix" ? c.dim("peers ") : "") + state)
289
+ // a newer release out of the range this CLI installs: current for this CLI — said, so it is no mystery
290
+ const outside = r.range && r.latest && r.target && semverNewer(r.latest, r.target) > 0 ? c.dim(` ${r.latest} is out, outside ${r.range}`) : ""
291
+ log(" " + r.name.padEnd(18) + " " + (r.installed ?? "").padEnd(8) + " " + (r.where === "prefix" ? c.dim("peers ") : "") + state + outside)
194
292
  }
195
293
  if (behind.length === 0) return "ok"
196
294
  const prefixBehind = behind.filter((r) => r.where === "prefix")
@@ -198,28 +296,20 @@ export default defineCommand({
198
296
  if (flags.check) {
199
297
  return ["updated", behind.length + " behind — run: lecodes update" + (globalSpecs.length ? " (global: " + commandLine(ownManager(), installGlobalArgv(ownManager(), globalSpecs)) + ")" : "")]
200
298
  }
201
- const done: string[] = []
202
- if (prefixBehind.length) {
203
- await installPeers(prefixBehind.map((r) => r.name), "lecodes update", { yes: true })
204
- done.push(...prefixBehind.map((r) => r.name))
205
- }
206
- if (STANDALONE && globalSpecs.some((s) => s.startsWith("lecodes-cli@"))) {
207
- note("this is the standalone build — the CLI updates by re-installing the release archive, not through npm; peers still go through npm")
208
- }
209
- const toInstall = STANDALONE ? globalSpecs.filter((s) => !s.startsWith("lecodes-cli@")) : globalSpecs
210
- if (toInstall.length) {
299
+ // an npm peer at the exact version reported (the range alone leaves a locked one where it is); an archive's
300
+ // spec is its versioned url already
301
+ const at = Object.fromEntries(prefixBehind.filter((r) => r.range !== null).map((r) => [ r.name, r.target! ]))
302
+ if (prefixBehind.length) await installPeers(prefixBehind.map((r) => r.name), "lecodes update", { yes: true, at })
303
+ if (globalSpecs.length) {
211
304
  // with the manager this CLI was installed by: another one's global dir is another copy
212
305
  const tool = ownManager()
213
- const argv = installGlobalArgv(tool, toInstall)
214
- log(" " + c.dim("$") + " " + commandLine(tool, argv))
215
- const r = spawnSync(tool, argv, { stdio: "inherit", shell: true })
306
+ const install = installGlobalArgv(tool, globalSpecs)
307
+ log(" " + c.dim("$") + " " + commandLine(tool, install))
308
+ const r = spawnSync(tool, install, { stdio: "inherit", shell: true })
216
309
  if (r.error) throw new CliError(tool + " could not run: " + r.error.message)
217
- if (r.status !== 0) throw new CliError(commandLine(tool, argv.slice(0, 2)) + " exited with " + (r.status ?? "a signal"))
218
- done.push(...behind.filter((r) => r.where !== "prefix" && (!STANDALONE || r.name !== "lecodes-cli")).map((r) => r.name))
310
+ if (r.status !== 0) throw new CliError(commandLine(tool, install.slice(0, 2)) + " exited with " + (r.status ?? "a signal"))
219
311
  }
220
- if (done.length === 0) return ["skipped", "only the standalone CLI itself is behind — download the release"]
221
- const cliToo = done.includes("lecodes-cli")
222
- return ["updated", done.join(", ") + " → latest" + (cliToo ? " (the new CLI runs from your next command)" : "")]
312
+ return ["updated", behind.map((r) => `${r.name} ${r.installed} → ${r.target}`).join(", ")]
223
313
  })
224
314
 
225
315
  // Summary — one line per step so a scrolled-away failure can't hide.
@@ -1,3 +1,4 @@
1
+ import { readAppEnv } from "../project/env"
1
2
  import { readFileSync } from "node:fs"
2
3
  import type { Manifest } from "../project/manifest"
3
4
  import { scanLocal } from "../project/localFiles"
@@ -95,6 +96,7 @@ export const compileDesignScreen = async (opts: {
95
96
  return compileProject({
96
97
  entries,
97
98
  name: opts.name,
99
+ env: readAppEnv(root),
98
100
  publicUrl: opts.assets === "disk" ? LOCAL_ASSET_SCHEME : "/project",
99
101
  entryOverride: entryPath,
100
102
  header: false,
@@ -1,3 +1,4 @@
1
+ import { readAppEnv } from "../project/env"
1
2
  import { getProject } from "../platform/api"
2
3
  import { loadConfig, requireApiUrl, requireToken } from "../platform/config"
3
4
  import { findProjectRoot, readManifest, type Manifest } from "../project/manifest"
@@ -123,6 +124,7 @@ export const compileHeadlessBundle = async (opts: HeadlessOptions = {}): Promise
123
124
  name,
124
125
  publicUrl,
125
126
  serverUrl,
127
+ env: readAppEnv(root),
126
128
  entryOverride: entryFlag,
127
129
  header: false,
128
130
  format: "iife",
@@ -7,7 +7,7 @@ import { installPeers } from "../hosts/peerInstall"
7
7
  import { peerDirInPrefix } from "../hosts/peers"
8
8
  import {
9
9
  compileProjectWithMap as sdkCompileProjectWithMap, compileServerBundle, detectEntry, inlineSourceMapComment, isServerPath, scanModulesChisel,
10
- localAssetNames, ENTRY_CONVENTION, type BundleBake, type CompileServerOptions,
10
+ localAssetNames, ENTRY_CONVENTION, findEnvLeaks, EnvLeakError, type BundleBake, type CompileServerOptions,
11
11
  } from "lecodes-sdk/compile"
12
12
 
13
13
  // Every project compiles through the SDK's one import-free compiler, which bundles with chisel (the
@@ -95,6 +95,10 @@ export type CompileOptions = {
95
95
  /** Base URL of the project's backend, baked into the stubs of its `*.server.ts` modules; absent =
96
96
  * every call to the server rejects (status 0). */
97
97
  serverUrl?: string
98
+ /** The project's keys (`readAppEnv(root)` — its .env): the `PUBLIC_*` ones are baked in as `env.PUBLIC_*`,
99
+ * and the bundle is searched for every other value — a secret pasted into app code fails the compile
100
+ * (`EnvLeakError`). Absent = no keys. */
101
+ env?: Record<string, string>
98
102
  }
99
103
 
100
104
  /** A warning when auto-detection has several import-graph roots and no main.ts to settle it —
@@ -136,7 +140,14 @@ export const compileProjectBundle = async (opts: CompileOptions): Promise<{ js:
136
140
  editor: opts.editor,
137
141
  onWarning: opts.onWarning,
138
142
  serverUrl: opts.serverUrl,
143
+ env: opts.env,
139
144
  })
145
+ // a secret's VALUE in the bundle — pasted into app code or an asset — is what `env` cannot prevent, and this is
146
+ // where the values are known (the project's .env)
147
+ if (opts.env) {
148
+ const leaked = findEnvLeaks(code, opts.env)
149
+ if (leaked.length) throw new EnvLeakError(leaked, "the app bundle")
150
+ }
140
151
  return { js: map ? `${code}\n${inlineSourceMapComment(map)}` : code, bakes }
141
152
  }
142
153
 
@@ -1,3 +1,4 @@
1
+ import { readAppEnv } from "../project/env"
1
2
  import { createHash } from "node:crypto"
2
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
4
  import { join } from "node:path"
@@ -93,7 +94,7 @@ export const compileSceneEditor = async (opts: {
93
94
  try {
94
95
  const js = await compileProject({
95
96
  entries, name: opts.name, publicUrl: "", entryOverride: SCENE_ENTRY_PATH,
96
- header: false, format: "iife", minify: false, editor: true,
97
+ header: false, format: "iife", minify: false, editor: true, env: readAppEnv(root),
97
98
  })
98
99
  return { js }
99
100
  } catch (e: any) {
@@ -5,7 +5,7 @@ import { join } from "node:path"
5
5
  import { format } from "node:util"
6
6
  import {
7
7
  createAuthHost, createFilesHost, createInvoker, createPushHost, createSubscribeGuard, describeServer, loadServerModules, runSeed,
8
- setChannelPublisher, setDbTransport, type AuthHost, type Heic, type LoadedServer, type PushRelay, type Sharp,
8
+ setChannelPublisher, setDbTransport, setEnv, type AuthHost, type Heic, type LoadedServer, type PushRelay, type Sharp,
9
9
  } from "lecodes-sdk/server"
10
10
  import { loadPeer } from "../hosts/peerInstall"
11
11
  import { ApiError, sendPush } from "../platform/api"
@@ -116,11 +116,16 @@ const load = async (m: Extract<ToWorker, { t: "load" }>) => {
116
116
  const noDb = () => { throw new Error("db: the database is not open yet — query it inside an endpoint, not while the module loads") }
117
117
  setDbTransport({ exec: (op) => (db ?? noDb()).exec(op), batch: (ops) => (db ?? noDb()).batch(ops) })
118
118
  setChannelPublisher((ch, group, data) => post({ t: "publish", ch, group, data }))
119
+ // The project's keys (its .env), before any module-level code can read one
120
+ setEnv(m.env)
119
121
  // The bundle is a plain script (chisel esm output with only side effects): registers globalThis.__lecodesServer
120
122
  ;(0, eval)(m.code)
121
123
  const server: LoadedServer = loadServerModules()
122
124
  const describe = describeServer(server)
123
125
  const notes = describe.marci ? await openDb(m.dbDir, m.filesDir, describe.marci) : []
126
+ // the keys the code reads and .env does not have: said now, not at the first failed call
127
+ const missing = describe.env.filter(name => !(name in m.env))
128
+ if (missing.length) notes.push(`env: ${missing.join(", ")} not set — add ${missing.length === 1 ? "it" : "them"} to the project's .env (NAME=value); a call that reads one fails until then`)
124
129
  // Sessions, passwords and email codes over the project's own db, as on the runner. A code that was
125
130
  // asked for is printed here, and DEV_CODE signs any email in without one — the developer is the
126
131
  // only user.
@@ -1,3 +1,4 @@
1
+ import { readAppEnv } from "../project/env"
1
2
  import { createHash } from "node:crypto"
2
3
  import { createReadStream, readFileSync, statSync } from "node:fs"
3
4
  import { join } from "node:path"
@@ -199,7 +200,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
199
200
  }
200
201
  const onWarning = (w: string) => { warnings.push(w) }
201
202
  const serverBundle = await backend.compile(entries)
202
- const js = await compileProject({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader, iconsHeader, onWarning, serverUrl: serverBundle ? serverUrl : undefined })
203
+ const js = await compileProject({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader, iconsHeader, onWarning, serverUrl: serverBundle ? serverUrl : undefined, env: readAppEnv(root) })
203
204
  // the page's own halves, before anything is told to reload: one that does not build fails the
204
205
  // compile, the page keeps the last good ones
205
206
  const halvesChanged = lite ? await buildHalves() : false
@@ -25,7 +25,7 @@ export type LogLevel = "log" | "info" | "warn" | "error" | "debug"
25
25
  type Ctx = Omit<RequestContext, "id">
26
26
 
27
27
  export type ToWorker =
28
- | { t: "load", code: string, dbDir: string, filesDir: string, root: string, push: PushSender | null }
28
+ | { t: "load", code: string, dbDir: string, filesDir: string, root: string, push: PushSender | null, env: Record<string, string> }
29
29
  | { t: "invoke", req: number, id: string, args: unknown[], ctx: Ctx }
30
30
  | { t: "subscribe", req: number, ch: string, args: unknown[], ctx: Ctx }
31
31
  | { t: "stop" }
@@ -57,8 +57,9 @@ export type LocalBackendOptions = {
57
57
  export type DeployResult = { changed: boolean, describe: ServerDescription, notes: string[] }
58
58
 
59
59
  export type LocalBackend = {
60
- /** Run this bundle from now on. Rejects with the reason when it does not load. */
61
- deploy: (code: string) => Promise<DeployResult>
60
+ /** Run this bundle from now on, with these keys (`env.NAME` in the bundle — the project's .env).
61
+ * A changed key alone is a deploy too. Rejects with the reason when it does not load. */
62
+ deploy: (code: string, env: Record<string, string>) => Promise<DeployResult>
62
63
  invoke: (id: string, args: unknown[], ctx: Ctx) => Promise<InvokeResult>
63
64
  /** A channel subscription: its hooks → the subscriber's group (the envelope's `result`). */
64
65
  subscribe: (ch: string, args: unknown[], ctx: Ctx) => Promise<InvokeResult>
@@ -92,8 +93,13 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
92
93
  const filesDir = options.filesDir ?? filesDirOf(dbDir)
93
94
 
94
95
  let live: Live | null = null
95
- /** The bundle that is deployed — what a crashed or timed-out process is started from again. */
96
+ /** The bundle that is deployed — what a crashed or timed-out process is started from again — and its keys. */
96
97
  let code: string | null = null
98
+ let env: Record<string, string> = {}
99
+ const sameEnv = (a: Record<string, string>, b: Record<string, string>) => {
100
+ const ka = Object.keys(a).sort(), kb = Object.keys(b).sort()
101
+ return ka.length === kb.length && ka.every((k, i) => k === kb[i] && a[k] === b[k])
102
+ }
97
103
  let starting: Promise<Live> | null = null
98
104
  let chain: Promise<unknown> = Promise.resolve()
99
105
  let nextReq = 1
@@ -107,7 +113,7 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
107
113
  l.pending.clear()
108
114
  }
109
115
 
110
- const start = (bundle: string): Promise<{ live: Live, notes: string[] }> => new Promise((resolve, reject) => {
116
+ const start = (bundle: string, keys: Record<string, string>): Promise<{ live: Live, notes: string[] }> => new Promise((resolve, reject) => {
111
117
  const [exe, ...args] = selfCommand()
112
118
  const child = spawn(exe!, [...args, BACKEND_WORKER_ARG], { cwd: options.root, stdio: ["ignore", "inherit", "inherit", "ipc"] })
113
119
  let exited!: () => void
@@ -152,7 +158,7 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
152
158
  if (!closed) log(`[server:error] the server process exited (${status ?? "killed"}) — it starts again with the next call`)
153
159
  }
154
160
  })
155
- child.send({ t: "load", code: bundle, dbDir, filesDir, root: options.root, push: options.push ?? null } satisfies ToWorker)
161
+ child.send({ t: "load", code: bundle, dbDir, filesDir, root: options.root, push: options.push ?? null, env: keys } satisfies ToWorker)
156
162
  })
157
163
 
158
164
  const stop = async (l: Live) => {
@@ -169,7 +175,7 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
169
175
  const ensure = async (): Promise<Live | null> => {
170
176
  await chain.catch(() => {})
171
177
  if (live || code === null || closed) return live
172
- starting ??= start(code).then((r) => { live = r.live; return r.live }).finally(() => { starting = null })
178
+ starting ??= start(code, env).then((r) => { live = r.live; return r.live }).finally(() => { starting = null })
173
179
  try { return await starting } catch (e) { log(`[server:error] ${(e as Error).message}`); return null }
174
180
  }
175
181
 
@@ -192,25 +198,26 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
192
198
  })
193
199
  }
194
200
 
195
- const deployNow = async (bundle: string): Promise<DeployResult> => {
196
- if (bundle === code && live) return { changed: false, describe: live.describe, notes: [] }
197
- const before = code
201
+ const deployNow = async (bundle: string, keys: Record<string, string>): Promise<DeployResult> => {
202
+ if (bundle === code && sameEnv(keys, env) && live) return { changed: false, describe: live.describe, notes: [] }
203
+ const before = code, beforeEnv = env
198
204
  if (live) await stop(live)
199
205
  try {
200
- const next = await start(bundle)
206
+ const next = await start(bundle, keys)
201
207
  live = next.live
202
208
  code = bundle
209
+ env = keys
203
210
  return { changed: true, describe: next.live.describe, notes: next.notes }
204
211
  } catch (e) {
205
212
  // the bundle that ran before answers the devices again (they still run its client)
206
- if (before !== null) await start(before).then((r) => { live = r.live }, () => {})
213
+ if (before !== null) await start(before, beforeEnv).then((r) => { live = r.live }, () => {})
207
214
  throw e
208
215
  }
209
216
  }
210
217
 
211
218
  return {
212
- deploy: (bundle) => {
213
- const run = chain.catch(() => {}).then(() => deployNow(bundle))
219
+ deploy: (bundle, keys) => {
220
+ const run = chain.catch(() => {}).then(() => deployNow(bundle, keys))
214
221
  chain = run
215
222
  return run
216
223
  },
@@ -4,6 +4,7 @@ import { createChannelHub, fileResponse, isMultipart, receiveUploads, UploadErro
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"
7
+ import { readAppEnv } from "../project/env"
7
8
  import { session } from "../commands/shared"
8
9
  import { readAppConfigAt } from "../commands/app/shared"
9
10
  import { filesDirOf, startLocalBackend, type LocalBackend } from "./localBackend"
@@ -104,13 +105,16 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
104
105
  if (/\bimage\s*\(/.test(bundle.code)) await loadPeer("sharp", undefined, { for: `${options.command} (the project's t.image() fields)` })
105
106
  }
106
107
  backend ??= start()
107
- const deployed = await backend.deploy(bundle.code)
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)
108
111
  running = bundle.code
109
112
  if (!deployed.changed) return []
110
113
  const d = deployed.describe
111
114
  const models = Object.keys(d.models).length
112
115
  const count = (n: number, what: string) => `${n} ${what}${n === 1 ? "" : "s"}`
113
- log(`server: ${count(d.endpoints.length, "endpoint")}, ${count(d.channels.length, "channel")}${models ? `, ${count(models, "model")} (database in ${relative(root, dbDir).replace(/\\/g, "/")})` : ""}`)
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)` : ""}`)
114
118
  return deployed.notes.map((n) => `server: ${n}`)
115
119
  },
116
120
  handle: async (req, route, ip) => {
@@ -156,7 +160,7 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
156
160
  rmSync(dbDir, { recursive: true, force: true })
157
161
  rmSync(filesDir, { recursive: true, force: true })
158
162
  backend = start()
159
- await backend.deploy(running)
163
+ await backend.deploy(running, readAppEnv(root))
160
164
  },
161
165
  close: async () => {
162
166
  for (const conn of [...sockets]) conn.close()
@@ -21,6 +21,8 @@ export const watchProject = (root: string, onChange: () => void, log: { warn(mes
21
21
  const counts = (file: string | null): boolean => {
22
22
  if (file === null) return true // an event without a name: assume the project's content
23
23
  const parts = file.replace(/\\/g, "/").split("/")
24
+ // the project's .env is never pushed (ignored), but the backend reads its keys: a save is a deploy
25
+ if (parts.length === 1 && parts[0] === ".env") return true
24
26
  if (parts.some((p) => SKIP_DIRS.has(p))) return false
25
27
  if (SKIP_FILES.has(parts[parts.length - 1]!)) return false
26
28
  try {
@@ -517,8 +517,12 @@ const netArgs = (net: DesktopWindowOpts["net"]): string[] => {
517
517
 
518
518
  export type DesktopRenderOptions = {
519
519
  /** Called per output chunk; returning true ends the run early (the host is killed, the
520
- * outcome is a success with the output so far — a bake that printed its done marker). */
520
+ * outcome is a success with the output so far — a bake that printed its done marker). The text
521
+ * handed over starts at the last line break seen before the chunk, so a marker line a pipe chunk
522
+ * cut in two still matches whole. */
521
523
  stopOnLine?: (chunk: string) => boolean
524
+ /** Called per output chunk as it arrives, the same text as stopOnLine's (a wall clock over the host's marker lines). */
525
+ onChunk?: (chunk: string) => void
522
526
  /** Which host binary to run (see DesktopRenderer). Must match the renderer the bundle's shaders
523
527
  * were staged for — resolve it once per command and pass the same value to both. */
524
528
  renderer?: DesktopRenderer
@@ -703,11 +707,15 @@ export const runDesktopRender = async (
703
707
  // live only with --logs. The error tail is the last 80 chunks.
704
708
  const chunks: string[] = []
705
709
  let stopped = false
710
+ let partial = "" // the unterminated last line of the previous chunk (stdout and stderr share it: markers are single lines)
706
711
  const sink = (d: unknown) => {
707
712
  const text = String(d)
708
713
  chunks.push(text)
709
714
  if (opts.logs) process.stderr.write(demangle(text))
710
- if (!stopped && opts.stopOnLine?.(text)) { stopped = true; child.kill() }
715
+ const lines = partial + text
716
+ partial = lines.slice(lines.lastIndexOf("\n") + 1)
717
+ opts.onChunk?.(lines)
718
+ if (!stopped && opts.stopOnLine?.(lines)) { stopped = true; child.kill() }
711
719
  }
712
720
  child.stdout.on("data", sink)
713
721
  child.stderr.on("data", sink)