lecodes-cli 2.4.10 → 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 +2510 -1035
  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 -79
  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 +7 -3
  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,3 +1,4 @@
1
+ import { readAppEnv } from "../project/env"
1
2
  import { writeFileSync } from "node:fs"
2
3
  import { basename, resolve } from "node:path"
3
4
  import { getProject } from "../platform/api"
@@ -229,7 +230,7 @@ export const buildBundle = async (bundle: BundleOptions, opts: BuildBundleOption
229
230
  }
230
231
  const serverUrl = await opts.backend?.start(entries, (w) => warnings.push(w))
231
232
  const { js, bakes } = await compileProjectBundle({
232
- entries, name, publicUrl, entryOverride, serverUrl,
233
+ entries, name, publicUrl, entryOverride, serverUrl, env: readAppEnv(root),
233
234
  header: bundle.header ?? true,
234
235
  localAssets, fontsHeader, iconsHeader,
235
236
  minify: bundle.minify,
@@ -0,0 +1,100 @@
1
+ import { existsSync, realpathSync } from "node:fs"
2
+ import { join, resolve } from "node:path"
3
+ import { CliError, UsageError, bool, c, defineCommand, log, note, prompt } from "../../cli"
4
+ import { createRepo, whoami, type HubWhoami } from "../../hub/api"
5
+ import { requireHubConfig } from "../../hub/config"
6
+ import { git, gitOrThrow } from "../../hub/git"
7
+
8
+ /*
9
+ * `lecodes hub create [ns/]name [--private]` — make the repo + project on hub for the app in this
10
+ * folder and connect the folder to it (LeCodesHub/docs/cli.md). A project is a folder with
11
+ * app.json and nothing else is required, so that is the one precondition. The order keeps a
12
+ * failure cheap: everything local that can refuse (no app.json, a `hub` remote already there) is
13
+ * checked before `POST /api/repos`; `git init -b main` and `git remote add hub` come after it.
14
+ *
15
+ * Without `ns/` the namespace comes from `GET /api/account`: the user's own account, or — when they
16
+ * are also in orgs and stdin is a terminal — the one they pick from a numbered list (own account
17
+ * first, the default). `ns` is always sent explicitly.
18
+ *
19
+ * The folder must be the ROOT of its git repo: hub reads `app.json` at the root of every pushed
20
+ * commit, so a project inside a bigger repo (a subfolder of a monorepo) gets its own repo here.
21
+ */
22
+
23
+ const samePath = (a: string, b: string): boolean => {
24
+ const norm = (p: string): string => {
25
+ let r = resolve(p)
26
+ try { r = realpathSync.native(r) } catch { /* as given */ }
27
+ return process.platform === "win32" ? r.toLowerCase() : r
28
+ }
29
+ return norm(a) === norm(b)
30
+ }
31
+
32
+ /** `den/demo` → { ns: "den", name: "demo" }; `demo` → { name: "demo" } (the server picks the namespace). */
33
+ export const parseRepoArg = (arg: string): { ns?: string, name: string } => {
34
+ const parts = arg.trim().split("/")
35
+ if (parts.length === 1 && parts[0]) return { name: parts[0] }
36
+ if (parts.length === 2 && parts[0] && parts[1]) return { ns: parts[0], name: parts[1] }
37
+ throw new UsageError(`"${arg}" is not [namespace/]name`)
38
+ }
39
+
40
+ /**
41
+ * The namespace to create in: the own account when it is the only one or nobody can answer
42
+ * (`ask` absent = stdin is not a terminal), else the user's pick — a number or a slug; Enter = 1.
43
+ */
44
+ export const pickNamespace = async (me: HubWhoami, ask?: (question: string, fallback: string) => Promise<string>): Promise<string> => {
45
+ const own = me.account.slug
46
+ const list = [...(me.namespaces ?? [])].sort((a, b) => Number(b.slug === own) - Number(a.slug === own))
47
+ if (!list.some(n => n.slug === own)) list.unshift({ id: me.account.id, slug: own, name: me.account.name, kind: me.account.kind })
48
+ if (list.length === 1 || !ask) return own
49
+ log("Create the repository in:")
50
+ list.forEach((n, i) => log(` ${i + 1}) ${c.bold(n.slug)}${c.dim(n.slug === own ? " your account" : ` ${n.kind}${n.role ? `, ${n.role}` : ""}`)}`))
51
+ const answer = (await ask("Namespace", "1")).trim()
52
+ const byNumber = /^\d+$/.test(answer) ? list[Number(answer) - 1] : undefined
53
+ const chosen = byNumber ?? list.find(n => n.slug === answer)
54
+ if (!chosen) throw new CliError(`"${answer}" is none of the ${list.length} namespaces listed.`)
55
+ return chosen.slug
56
+ }
57
+
58
+ export default defineCommand({
59
+ name: "create",
60
+ summary: "Create a hub repo + project for the app in this folder and add it as the `hub` git remote",
61
+ usage: "[ns/]name",
62
+ description: "Needs app.json in the current folder (run `lecodes init` first otherwise). Calls hub to create the repository and its project (one production environment, deployed from `main`), runs `git init -b main` when the folder is not a repository yet, and adds the repository as the remote `hub`. Deploying is then a plain `git push hub main`.\n\nWithout a namespace the repo goes to your own account; when you are also a member of orgs, the CLI lists them and asks (in a terminal; otherwise your own account). The project is public unless `--private`.",
63
+ flags: {
64
+ private: bool("make the repository and its deployments private"),
65
+ },
66
+ examples: ["lecodes hub create demo", "lecodes hub create den/demo --private"],
67
+ run: async ({ args, flags }) => {
68
+ const cwd = process.cwd()
69
+ if (!existsSync(join(cwd, "app.json"))) {
70
+ throw new CliError("No app.json in this folder — a hub project is a folder with app.json. Run `lecodes init` first (or cd into the project).")
71
+ }
72
+ if (!args[0]) throw new UsageError("name the repository: lecodes hub create [ns/]name")
73
+ const { ns, name } = parseRepoArg(args[0])
74
+ const config = requireHubConfig()
75
+
76
+ const top = git(["rev-parse", "--show-toplevel"], cwd)
77
+ const isRepo = top.ok && samePath(top.stdout, cwd)
78
+ if (isRepo) {
79
+ const existing = git(["remote", "get-url", "hub"], cwd)
80
+ if (existing.ok) throw new CliError(`This repository already has a "hub" remote (${existing.stdout}). Remove it first: git remote remove hub`)
81
+ }
82
+
83
+ const namespace = ns ?? await pickNamespace(await whoami(config.url, config.token), process.stdin.isTTY ? prompt : undefined)
84
+ const visibility = flags.private ? "private" : "public"
85
+ const created = await createRepo(config.url, config.token, { ns: namespace, name, visibility })
86
+ if (!created || typeof created.remote !== "string") throw new CliError("hub answered without the repository's remote URL.")
87
+
88
+ if (!isRepo) {
89
+ if (top.ok) note(`(inside the repository at ${top.stdout} — this folder gets a repository of its own)`)
90
+ gitOrThrow(["init", "-b", "main"], cwd)
91
+ }
92
+ gitOrThrow(["remote", "add", "hub", created.remote], cwd)
93
+
94
+ const branch = git(["symbolic-ref", "--short", "HEAD"], cwd)
95
+ const pushLine = !branch.ok || branch.stdout === "main" ? "git push hub main" : `git push hub ${branch.stdout}:main`
96
+ log(`Created ${c.bold(created.remote.replace(/\.git$/, ""))} (${visibility})`)
97
+ log(`Remote "hub" added. Deploy with:`)
98
+ log(` ${pushLine}`)
99
+ },
100
+ })
@@ -0,0 +1,32 @@
1
+ import { defineCommand } from "../../cli"
2
+ import { loadHubConfig } from "../../hub/config"
3
+ import { credentialAnswer } from "../../hub/git"
4
+
5
+ /*
6
+ * `lecodes hub credential <get|store|erase>` — git's credential-helper protocol over
7
+ * ~/.lecodes/hub.json; git runs it (`hub login` configures `credential.<hub>.helper`), a person
8
+ * does not, so it stays out of the help. git writes `key=value` lines on stdin; `get` answers
9
+ * `username=token` / `password=<PAT>` on stdout only for the stored hub's host
10
+ * (src/hub/git.ts `credentialAnswer`), and says nothing otherwise. `store` and `erase` are no-ops:
11
+ * the token's only home is hub.json, and a refused push must not delete it (`hub login` replaces it).
12
+ */
13
+
14
+ const readStdin = async (): Promise<string> => {
15
+ if (process.stdin.isTTY) return ""
16
+ const chunks: Buffer[] = []
17
+ for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
18
+ return Buffer.concat(chunks).toString("utf8")
19
+ }
20
+
21
+ export default defineCommand({
22
+ name: "credential",
23
+ summary: "git credential helper for hub (internal: git runs it)",
24
+ usage: "<get|store|erase>",
25
+ hidden: true,
26
+ run: async ({ args }) => {
27
+ const action = args[0] ?? ""
28
+ const input = await readStdin()
29
+ const answer = credentialAnswer(action, input, loadHubConfig())
30
+ if (answer) process.stdout.write(answer)
31
+ },
32
+ })
@@ -0,0 +1,21 @@
1
+ import { defineCommand } from "../../cli"
2
+ import create from "./create"
3
+ import credential from "./credential"
4
+ import login from "./login"
5
+ import status from "./status"
6
+
7
+ /*
8
+ * `lecodes hub …` — the projects on hub (hub.le.codes): git repositories that deploy on push
9
+ * (LeCodesHub/docs/cli.md). The group adds only what git cannot do: signing in (and git's
10
+ * credentials with it), creating the repo + project, and reading the deployments. Pushing is
11
+ * `git push hub main`. The older `login` / `publish` / `releases` / `pull` / `push` / `clone` keep
12
+ * talking to le.codes and share nothing with this group (its own ~/.lecodes/hub.json, its own client
13
+ * in src/hub).
14
+ */
15
+ export default defineCommand({
16
+ name: "hub",
17
+ summary: "Projects on hub.le.codes — git repositories that deploy on `git push`: login, create, status",
18
+ description: "A hub project is a git repository with app.json at its root; every push to `main` deploys to production and every other branch gets a preview URL. `lecodes hub login` signs in and sets up git's credentials, `lecodes hub create` makes the repository and adds the `hub` remote, `lecodes hub status` shows the environments and deployments. Everything else is git.",
19
+ commands: [login, create, status, credential],
20
+ examples: ["lecodes hub login", "lecodes hub create demo --private", "git push hub main", "lecodes hub status --follow"],
21
+ })
@@ -0,0 +1,89 @@
1
+ import { hostname } from "node:os"
2
+ import { CliError, c, defineCommand, log, note, str, success, warnErr } from "../../cli"
3
+ import { HubApiError, pollDeviceLogin, startDeviceLogin, whoami, type HubWhoami } from "../../hub/api"
4
+ import { DEFAULT_HUB, loadHubConfig, normalizeHubUrl, saveHubConfig } from "../../hub/config"
5
+ import { configureCredentialHelper, tokenProtocol } from "../../hub/git"
6
+ import { openBrowser } from "../../platform/browserAuth"
7
+
8
+ /*
9
+ * `lecodes hub login` — sign this machine in to hub (LeCodesHub/docs/cli.md, accounts.md "Device
10
+ * flow"). RFC 8628 device flow: ask hub for a device code, show the user code and open the
11
+ * verification page, poll until the signed-in user approves; the answer is a normal PAT (scopes
12
+ * `repo` + `deploy`, named `cli on <hostname>`). `--token lch_…` stores a PAT made in the web
13
+ * instead. Either way the token is checked with `GET /api/account` before anything is written (a
14
+ * refused token leaves hub.json and the git config as they were), then it goes to ~/.lecodes/hub.json (0600) and git's credential helper for
15
+ * the hub host is pointed at `lecodes hub credential`, so `git push hub main` authenticates with no
16
+ * plaintext copy in `.git-credentials`.
17
+ */
18
+
19
+ /** `lch_` + 40 of `[a-z0-9]` (accounts.md "Personal access tokens"). */
20
+ const PAT = /^lch_[a-z0-9]{40}$/
21
+
22
+ const SCOPES = ["repo", "deploy"]
23
+
24
+ /** The account behind `token`; a 401 / 403 is "this token is refused", nothing else is asked of it. */
25
+ const checkToken = async (url: string, token: string): Promise<HubWhoami> => {
26
+ try {
27
+ return await whoami(url, token)
28
+ } catch (e) {
29
+ if (e instanceof HubApiError && (e.status === 401 || e.status === 403)) {
30
+ throw new CliError(`${url} refused the token (${e.status}) — it is wrong, revoked or expired. Nothing was saved.`, e.status)
31
+ }
32
+ throw e
33
+ }
34
+ }
35
+
36
+ const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
37
+
38
+ /** The device flow against `base`: resolves the PAT once the user approved in the browser. */
39
+ export const deviceLogin = async (base: string, open: (url: string) => void = openBrowser, wait = sleep): Promise<string> => {
40
+ const start = await startDeviceLogin(base, { scopes: SCOPES, name: `cli on ${hostname()}` })
41
+ const page = new URL(start.verificationUrl, base)
42
+ if (!page.searchParams.has("code")) page.searchParams.set("code", start.userCode)
43
+ log(`Open ${c.bold(page.toString())}`)
44
+ log(`and confirm the code ${c.bold(start.userCode)}`)
45
+ note("(waiting for the approval in the browser — Ctrl+C to cancel)")
46
+ open(page.toString())
47
+
48
+ let interval = Math.max(1, start.interval || 5) * 1000
49
+ const deadline = Date.now() + Math.max(1, start.expiresIn || 600) * 1000
50
+ for (;;) {
51
+ await wait(interval)
52
+ if (Date.now() > deadline) break
53
+ const poll = await pollDeviceLogin(base, start.deviceCode)
54
+ if (poll.kind === "token") return poll.token
55
+ if (poll.kind === "expired") break
56
+ if (poll.kind === "slow-down") interval += 5000
57
+ }
58
+ throw new CliError("The login request expired before it was approved. Run `lecodes hub login` again.")
59
+ }
60
+
61
+ export default defineCommand({
62
+ name: "login",
63
+ summary: "Sign this machine in to hub (browser device flow) and set up git's credentials for it",
64
+ description: "Prints a code and opens hub's approval page; once you approve it there (signed in), the CLI receives a personal access token (scopes repo + deploy, named `cli on <hostname>`) and stores it in ~/.lecodes/hub.json (mode 0600). git's credential helper for the hub host is then pointed at `lecodes hub credential` in your global git config, so `git push hub main` needs no password and no token is written to .git-credentials.\n\n`--token` stores a token you created on hub instead (CI, a machine without a browser). Either way hub is asked who the token belongs to first; a refused token saves nothing. `--url` points at another hub, a local one included (`http://` is accepted for localhost only).",
65
+ flags: {
66
+ token: str("store this personal access token instead of the browser flow", { value: "lch_…" }),
67
+ url: str(`the hub (default: the stored one, else ${DEFAULT_HUB})`, { value: "<url>" }),
68
+ },
69
+ examples: ["lecodes hub login", "lecodes hub login --token lch_…", "lecodes hub login --url http://localhost:3000"],
70
+ run: async ({ flags }) => {
71
+ const url = normalizeHubUrl(flags.url ?? loadHubConfig()?.url ?? DEFAULT_HUB)
72
+ if (!tokenProtocol(url)) throw new CliError(`${url}: a hub is reached over https (plain http only on localhost) — the token would travel in clear.`)
73
+ let token: string
74
+ if (flags.token !== undefined) {
75
+ token = flags.token.trim()
76
+ if (!PAT.test(token)) throw new CliError("--token: a hub token is `lch_` followed by 40 lowercase letters and digits.")
77
+ } else token = await deviceLogin(url)
78
+
79
+ const me = await checkToken(url, token)
80
+ const missing = SCOPES.filter(s => !(me.scopes ?? []).includes(s))
81
+ if (missing.length) warnErr(`The token lacks the ${missing.join(" + ")} scope${missing.length > 1 ? "s" : ""}: ${missing.includes("repo") ? "git push" : "the deployments API"} will be refused.`)
82
+
83
+ const file = saveHubConfig({ url, token })
84
+ const { key } = configureCredentialHelper(url)
85
+ success(`Signed in as ${c.bold(me.account.slug)} on ${url}`)
86
+ note(` token: ${file}`)
87
+ note(` git: ${key} → lecodes hub credential (global config)`)
88
+ },
89
+ })
@@ -0,0 +1,109 @@
1
+ import { CliError, bool, c, defineCommand, log, note, relativeTime, table } from "../../cli"
2
+ import { HubApiError, followDeployLog, getDeploy, getProject, listDeploys, type HubDeploy, type HubProject } from "../../hub/api"
3
+ import { loadHubConfig } from "../../hub/config"
4
+ import { credentialScope, git, parseHubRemote } from "../../hub/git"
5
+ import { openBrowser } from "../../platform/browserAuth"
6
+
7
+ /*
8
+ * `lecodes hub status [--follow] [--open]` — the hub project of the repository in this folder,
9
+ * found by its `hub` remote (LeCodesHub/docs/cli.md): the environments with what they serve, then
10
+ * the last deployments. The API origin is the remote's own origin, and the stored token is sent
11
+ * only when that is the hub `hub login` signed in to — a public project answers without one, and a
12
+ * token is never handed to a host it was not minted for. `--follow` streams the running build's log
13
+ * (SSE, `…/log?follow=1`; one event = one or more whole lines) and ends with its outcome.
14
+ */
15
+
16
+ const LAST = 10
17
+
18
+ const paintStatus = (status: string): string =>
19
+ status === "ready" ? c.green(status)
20
+ : status === "failed" ? c.red(status)
21
+ : status === "building" || status === "queued" ? c.yellow(status)
22
+ : c.dim(status)
23
+
24
+ const oneLine = (s: string | null | undefined, max = 48): string => {
25
+ const line = (s ?? "").split("\n")[0].trim()
26
+ return line.length > max ? line.slice(0, max - 1) + "…" : line
27
+ }
28
+
29
+ const url = (host: string): string => `https://${host}`
30
+
31
+ const refName = (ref: string): string => ref.replace(/^refs\/(heads|tags)\//, "")
32
+
33
+ export default defineCommand({
34
+ name: "status",
35
+ summary: "Show the hub project of this repository: environments, the last deployments, a running build's log",
36
+ description: "Reads the `hub` git remote (added by `lecodes hub create`) to find the project, then prints each environment with the deployment it serves and the last deployments with their status and URLs. `--follow` streams the log of the build that is running (or queued) until it finishes; `--open` opens the project's page on hub.",
37
+ flags: {
38
+ follow: bool("stream the running build's log until it finishes"),
39
+ open: bool("open the project's page in the browser"),
40
+ },
41
+ examples: ["lecodes hub status", "lecodes hub status --follow"],
42
+ run: async ({ flags }) => {
43
+ const remote = git(["remote", "get-url", "hub"])
44
+ if (!remote.ok) throw new CliError("This repository has no \"hub\" remote. Create the project with `lecodes hub create <name>` (or add the remote: git remote add hub <url>).")
45
+ const where = parseHubRemote(remote.stdout)
46
+ if (!where) throw new CliError(`The "hub" remote (${remote.stdout}) is not a hub repository URL (https://<hub>/<namespace>/<name>.git).`)
47
+ const { base, ns, name } = where
48
+ const config = loadHubConfig()
49
+ const token = config && credentialScope(config.url) === credentialScope(base) ? config.token : undefined
50
+ const page = `${base}/${ns}/${name}`
51
+
52
+ if (flags.open) openBrowser(page)
53
+
54
+ let project: HubProject, deploys: HubDeploy[]
55
+ try {
56
+ [project, deploys] = await Promise.all([getProject(base, token, ns, name), listDeploys(base, token, ns, name)])
57
+ } catch (e) {
58
+ if (e instanceof HubApiError && e.status === 404) {
59
+ throw new CliError(`${page}: no such project${token ? "" : " (or it is private — sign in with `lecodes hub login`)"}.`, 404)
60
+ }
61
+ throw e
62
+ }
63
+
64
+ log(`${c.bold(`${ns}/${name}`)} ${page}${project.project?.visibility ? c.dim(` (${project.project.visibility})`) : ""}`)
65
+
66
+ log("")
67
+ log(c.bold("Environments"))
68
+ if (!project.environments?.length) log(c.dim(" none"))
69
+ else table(project.environments.map(env => {
70
+ const cur = env.current
71
+ return [
72
+ env.name,
73
+ url(env.host),
74
+ cur ? `#${cur.number} ${paintStatus(cur.status)}` : c.dim("nothing deployed"),
75
+ cur ? cur.commit.slice(0, 7) : "",
76
+ cur ? relativeTime(cur.finishedAt ?? cur.createdAt) : "",
77
+ ]
78
+ }))
79
+
80
+ log("")
81
+ log(c.bold("Deployments"))
82
+ const last = deploys.slice(0, LAST)
83
+ if (!last.length) log(c.dim(" none yet — deploy with: git push hub main"))
84
+ else table(last.map(d => [
85
+ `#${d.number}`,
86
+ paintStatus(d.status),
87
+ d.environment ?? c.dim("preview"),
88
+ refName(d.ref),
89
+ d.commit.slice(0, 7),
90
+ oneLine(d.message),
91
+ relativeTime(d.createdAt),
92
+ d.status === "ready" ? url(d.host) : c.dim(url(d.host)),
93
+ ]))
94
+
95
+ if (!flags.follow) return
96
+ const running = deploys.find(d => d.status === "building") ?? deploys.find(d => d.status === "queued")
97
+ if (!running) { log(""); note("No build is running."); return }
98
+ log("")
99
+ note(`Following #${running.number} (${running.status}) — Ctrl+C stops following, not the build`)
100
+ await followDeployLog(base, token, ns, name, running.number, text => { process.stdout.write(text.endsWith("\n") ? text : text + "\n") })
101
+ const done = await getDeploy(base, token, ns, name, running.number)
102
+ log("")
103
+ if (done.status === "ready") log(`#${done.number} ${paintStatus(done.status)} → ${url(done.host)}`)
104
+ else {
105
+ log(`#${done.number} ${paintStatus(done.status)}${done.error ? `: ${done.error}` : ""}`)
106
+ if (done.status === "failed") process.exitCode = 1
107
+ }
108
+ },
109
+ })
@@ -7,6 +7,7 @@ import create from "./create"
7
7
  import design from "./design"
8
8
  import desktop from "./desktop"
9
9
  import dev from "./dev"
10
+ import hub from "./hub"
10
11
  import diff from "./diff"
11
12
  import init from "./init"
12
13
  import install from "./install"
@@ -20,10 +21,12 @@ import publish from "./publish"
20
21
  import pull from "./pull"
21
22
  import push from "./push"
22
23
  import releases from "./releases"
24
+ import secrets from "./secrets"
23
25
  import render from "./render"
24
26
  import scene from "./scene"
25
27
  import serve from "./serve"
26
28
  import shaders from "./shaders"
29
+ import level from "./level"
27
30
  import status from "./status"
28
31
  import test from "./test"
29
32
  import thumbs from "./thumbs"
@@ -35,7 +38,7 @@ import web from "./web"
35
38
  * here, in the group it belongs to (CONTRIBUTING.md). Order within a group is the help's order.
36
39
  */
37
40
  export const commandGroups = (opts: { install: boolean }): Group[] => [
38
- { title: "Project", commands: [init, create, clone, link, status, diff, pull, push, publish, releases, login] },
41
+ { title: "Project", commands: [init, create, clone, link, status, diff, pull, push, publish, releases, secrets, login, hub] },
39
42
  { title: "Run & test", commands: [
40
43
  dev,
41
44
  web,
@@ -53,6 +56,7 @@ export const commandGroups = (opts: { install: boolean }): Group[] => [
53
56
  { title: "Content", commands: [
54
57
  assets,
55
58
  shaders,
59
+ level,
56
60
  lightmap,
57
61
  navmesh,
58
62
  ] },
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `lecodes level build` — the level's derived artifacts from ONE run of the level: the static level cache and the
3
+ * navigation mesh. The level is run the slow, complete way through the windowless host with the entry put in the
4
+ * tool runs' mode (`globalThis.__lecodesStatic` + `__lecodesNavmesh`, then `import "./<entry>"`); once it is loaded
5
+ * and its bake (if any) bound, the SDK snapshots the covered static subtrees into `<bake stem>.static` beside the
6
+ * `.bake` (gl/Statics.ts: placements, bodies, the bake's records per primitive) and dumps the static collision
7
+ * geometry, which Recast turns into the scene's own `env.navmesh` file here.
8
+ *
9
+ * What each tool owns: the SCENE FILE is the source (the `static` verdict, `foliage`, the bake() target, env.navmesh);
10
+ * the RUNTIME builds and snapshots; `lecodes lightmap bake` (lecodes-bake) traces and, when a cache exists, stitches
11
+ * its fresh records into it (a join by node path, no host run); this command joins the rest. Rerun it after a
12
+ * re-import or an edit of the statics (the cache carries their digest and declines itself until then); a rebake
13
+ * alone needs no rerun.
14
+ */
15
+
16
+ import { existsSync, mkdtempSync, rmSync } from "node:fs"
17
+ import { tmpdir } from "node:os"
18
+ import { basename, dirname, join, resolve } from "node:path"
19
+ import { buildBundle } from "./compile"
20
+ import { readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
21
+ import { runDesktopRender, type HostKind } from "../hosts/desktopRenderer"
22
+ import { CliError, bool, c, defineCommand, logErr, note, num, str, warnErr } from "../cli"
23
+ import { bundleFlags, bundleOptions, localProject } from "./shared"
24
+ import { NAVMESH_FLAGS, finishNavmesh } from "./navmesh"
25
+
26
+ const build = defineCommand({
27
+ name: "build",
28
+ summary: "The level's static cache + navmesh from one run of the level",
29
+ description: "Runs the level once through the windowless host (lecodes-server beside the desktop host; --host desktop for the window host) and writes: <bake stem>.static beside the level's .bake — the static subtree (models, colliders, the bake's rects per primitive) the engine rebuilds natively on every later load — and, when the level declares env.navmesh, its navmesh file through Recast (the one the scene names; a level's FIRST navmesh is `lecodes navmesh bake`'s, which starts from a placeholder). Rerun after a re-import or an edit of the statics; `lecodes lightmap bake` keeps the cache's records current on its own.",
30
+ flags: {
31
+ ...bundleFlags,
32
+ entry: str("the file that opens the level: a scene file with env.lightmap (and env.navmesh)", { value: "<file>", default: "main.ts" }),
33
+ ...NAVMESH_FLAGS,
34
+ host: str("which host runs the level: server (windowless, default) or desktop", { value: "<server|desktop>", default: "server" }),
35
+ frames: num("host frames to run before giving up — load headroom", { default: 6000 }),
36
+ timeout: num("host time cap (ms)", { default: 600000 }),
37
+ width: num("host window width (default: app.json desktop.window, else 1280)"),
38
+ renderer: str("desktop host + shader variant (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
39
+ logs: bool("forward the host's output"),
40
+ },
41
+ examples: ["lecodes level build --entry chernobyl.ts", "lecodes level build --entry chernobyl.ts --host desktop --logs"],
42
+ run: async ({ flags }) => {
43
+ const { root } = localProject()
44
+ const entryFile = flags.entry
45
+ if (!existsSync(resolve(root, entryFile))) {
46
+ throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that opens the level: a scene file with env.lightmap).`)
47
+ }
48
+ const host: HostKind = flags.host === "desktop" ? "desktop" : "server"
49
+ const frames = Math.max(30, Math.min(100000, flags.frames || 6000))
50
+ const win = readAppConfigAt(root)?.desktop?.window
51
+ const width = flags.width || win?.width || 1280
52
+ const height = win?.height || 720
53
+ const logs = flags.logs
54
+
55
+ // the entry in the tool runs' mode; the navmesh's place is filled in below, once the compile names the scene's file
56
+ const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
57
+ const entryPath = "/.level-entry.ts"
58
+ const cfgStatic = { root: root.replace(/\\/g, "/") }
59
+ const desktopRenderer = host === "server" ? ("gl" as const) : resolveDesktopRenderer(root, flags.renderer)
60
+ const compile = (navOutDir: string, navName: string) => buildBundle(bundleOptions(flags), {
61
+ localAssets: true, desktopRenderer,
62
+ extraEntries: [{ path: entryPath, type: "text", text: `globalThis.__lecodesStatic = ${JSON.stringify(cfgStatic)}\nglobalThis.__lecodesNavmesh = ${JSON.stringify({ outDir: navOutDir.replace(/\\/g, "/"), name: navName })}\nimport ${JSON.stringify(importPath)}\n` }],
63
+ entryOverride: entryPath,
64
+ })
65
+ // the navmesh goes where the scene's env.navmesh file is: the one .navmesh resource the bundle names
66
+ let { js, resources } = await compile(root, "level")
67
+ const named = new Set([ ...js.matchAll(/(?:fetchLocal|_creatorFetch\.local)\("([^"]+\.navmesh)"\)/g) ].map((m) => m[1]))
68
+ const navFiles = resources.filter((r) => !r.remote && r.absPath && named.has(r.name)).map((r) => r.absPath!)
69
+ let navOut: string | null = null, navName: string | null = null
70
+ if (navFiles.length === 1) {
71
+ navOut = dirname(navFiles[0]); navName = basename(navFiles[0]).replace(/\.navmesh$/, "")
72
+ ;({ js, resources } = await compile(navOut, navName))
73
+ } else if (navFiles.length > 1) {
74
+ throw new CliError(`${entryFile} reaches ${navFiles.length} navmesh files (${navFiles.map((f) => basename(f)).join(", ")}) — build one level per entry.`)
75
+ }
76
+
77
+ const scratch = mkdtempSync(join(tmpdir(), "lecodes-level-"))
78
+ const outPng = join(scratch, "frame.png")
79
+ note(`Loading the level through the ${host} host: the static cache${navName ? `, the collision geometry for ${navName}.navmesh` : ""}…`)
80
+ let output = ""
81
+ try {
82
+ const res = await runDesktopRender(js, resources, {
83
+ host, renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs: flags.timeout || 600000,
84
+ stopOnLine: (chunk) => chunk.includes("[static] done") || chunk.includes("[static] error"),
85
+ })
86
+ output = res.output
87
+ } catch (e) {
88
+ const msg = e instanceof Error ? e.message : String(e)
89
+ if (!/\[static\] done/.test(msg)) throw e
90
+ output = msg
91
+ } finally {
92
+ rmSync(scratch, { recursive: true, force: true })
93
+ }
94
+
95
+ // the static cache: the SDK's `[static]` lines
96
+ const lines = output.split(/\r?\n/).filter((l) => l.includes("[static]")).map((l) => l.slice(l.indexOf("[static]") + "[static]".length).trim())
97
+ let wrote: string | null = null
98
+ for (const text of lines) {
99
+ if (text === "done") continue
100
+ const m = /^wrote (.+?): (\d.*)$/.exec(text)
101
+ if (m) { wrote = m[1]; logErr(c.dim(` ${m[2]}`)); continue }
102
+ logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : c.dim(` ${text}`))
103
+ }
104
+ if (!/\[static\] done/.test(output)) {
105
+ warnErr("The host ran to its frame cap without writing the cache. Make sure the entry opens a scene file with env.lightmap (the cache lives beside the bake) and skips menus under a tool run. More load headroom: --frames <n>. Host output: --logs.")
106
+ throw new CliError("Nothing was written.")
107
+ }
108
+ if (!wrote) throw new CliError("The snapshot failed — see the lines above.")
109
+ logErr(c.green(`✓ static cache → ${wrote.replace(cfgStatic.root + "/", "")}`))
110
+ if (/no bake bound/.test(output)) logErr(c.dim(" (unbaked: placements and bodies only — `lecodes lightmap bake` adds the rects to it)"))
111
+
112
+ // the navmesh: only a level that declares env.navmesh dumped geometry
113
+ if (navOut && navName && /\[navmesh\] (config|error)/.test(output)) await finishNavmesh(root, navOut, navName, output, flags)
114
+ else logErr(c.dim(" no navmesh: the level declares no env.navmesh file"))
115
+ logErr(c.dim(" rerun after a re-import or an edit of the statics; a rebake (lecodes lightmap bake) updates the cache on its own"))
116
+ },
117
+ })
118
+
119
+ export default defineCommand({
120
+ name: "level",
121
+ summary: "A level's derived artifacts: the static cache and the navmesh (build)",
122
+ commands: [build],
123
+ })