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.
- package/dist/index.js +2681 -1196
- package/package.json +10 -10
- package/runtime/materials/decal-relief.mat +21 -46
- package/runtime/materials/decal.mat +41 -61
- package/runtime/materials/foliage-cards.mat +28 -140
- package/runtime/materials/foliage-impostor.mat +485 -0
- package/runtime/materials/foliage.mat +232 -0
- package/runtime/materials/grass.mat +244 -0
- package/runtime/materials/particles-quad.mat +5 -3
- package/runtime/materials/particles.mat +5 -3
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/materials-Cs_z1zLi.bin +0 -0
- package/runtime/web/assets/materials-world-BtoAfznA.bin +0 -0
- package/runtime/web/player.js +3 -3
- package/runtime/web-lite/assets/{canvas-WxiusXCB.js → canvas-wFjS40IX.js} +1 -1
- package/runtime/web-lite/assets/creator2d-1pSkYRCn.js +1 -0
- package/runtime/web-lite/assets/creator2d-Ci5-pZGr.wasm +0 -0
- package/runtime/web-lite/assets/{pager-DpGa2uaE.js → pager-BZAO9gMC.js} +1 -1
- package/runtime/web-lite/assets/{plugins-CuEPHbHL.js → plugins-B7TuO_p4.js} +1 -1
- package/runtime/web-lite/assets/scene2d-F6T1Io7I.js +1 -0
- package/runtime/web-lite/assets/{tween-BnWYhJ7P.js → tween-DbrQTld4.js} +1 -1
- package/runtime/web-lite/assets/{video-BLj61nDx.js → video-bzR_t2i6.js} +1 -1
- package/runtime/web-lite/assets/{vlist-CwSYpLz-.js → vlist-Ap-F4H5J.js} +1 -1
- package/runtime/web-lite/features.json +18 -6
- package/runtime/web-lite/lite.js +6 -6
- package/src/commands/compile.ts +2 -1
- package/src/commands/hub/create.ts +100 -0
- package/src/commands/hub/credential.ts +32 -0
- package/src/commands/hub/index.ts +21 -0
- package/src/commands/hub/login.ts +89 -0
- package/src/commands/hub/status.ts +109 -0
- package/src/commands/index.ts +5 -1
- package/src/commands/level.ts +123 -0
- package/src/commands/lightmap.ts +41 -76
- package/src/commands/navmesh.ts +84 -67
- package/src/commands/secrets.ts +147 -0
- package/src/commands/update/index.ts +154 -64
- package/src/compile/designCompile.ts +2 -0
- package/src/compile/headlessBundle.ts +2 -0
- package/src/compile/projectCompile.ts +12 -1
- package/src/compile/sceneCompile.ts +2 -1
- package/src/dev/backendWorker.ts +6 -1
- package/src/dev/devServer.ts +2 -1
- package/src/dev/localBackend.ts +21 -14
- package/src/dev/projectBackend.ts +7 -3
- package/src/dev/watchProject.ts +2 -0
- package/src/hosts/desktopRenderer.ts +10 -2
- package/src/hosts/peerInstall.ts +26 -6
- package/src/hub/api.ts +220 -0
- package/src/hub/config.ts +53 -0
- package/src/hub/git.ts +148 -0
- package/src/platform/api.ts +21 -0
- package/src/project/env.ts +29 -0
- package/src/web/build.ts +149 -88
- package/runtime/web/assets/materials-CJzxzdUK.bin +0 -0
- package/runtime/web/assets/materials-world-BNQb0Z1l.bin +0 -0
package/src/commands/lightmap.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { tmpdir } from "node:os"
|
|
3
3
|
import { basename, dirname, join, relative, resolve, sep } from "node:path"
|
|
4
4
|
import { deflateSync } from "node:zlib"
|
|
@@ -8,6 +8,7 @@ import { runDesktopRender } from "../hosts/desktopRenderer"
|
|
|
8
8
|
import { loadPeer } from "../hosts/peerInstall"
|
|
9
9
|
import { CliError, bool, c, defineCommand, log, logErr, note, num, str, warnErr } from "../cli"
|
|
10
10
|
import { bundleFlags, bundleOptions, localProject } from "./shared"
|
|
11
|
+
import { decodeStatics, encodeStatics, stitchStatics } from "lecodes-sdk/compile"
|
|
11
12
|
|
|
12
13
|
/*
|
|
13
14
|
* `lecodes lightmap` — the baked lighting of a level (engines/bake).
|
|
@@ -29,9 +30,10 @@ import { bundleFlags, bundleOptions, localProject } from "./shared"
|
|
|
29
30
|
* The bake is not in the shipped desktop / server hosts: it runs in lecodes-bake (hosts/desktop CREATOR_DESKTOP_BAKE),
|
|
30
31
|
* the windowless host with the bake built in — resolved by hosts/desktopRenderer.ts resolveBakeExe (LECODES_BAKE_EXE
|
|
31
32
|
* for a local build, else the published release product "bake").
|
|
32
|
-
* Lightmap UVs:
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* Lightmap UVs: a static without TEXCOORD_1 is unwrapped BY THE BAKE (creator-bake unwrap.cpp): its charts go into the
|
|
34
|
+
* -uv1.bin sidecar with the split geometry the runtime rebuilds the primitive from, and no GLB of the project is ever
|
|
35
|
+
* written. Vegetation (the foliage tier) is never unwrapped: it reads the ground's light. A file that carries its own
|
|
36
|
+
* lightmap uvs (`lecodes assets unwrap`, another tool) keeps them: the bake packs those charts as they are.
|
|
35
37
|
*/
|
|
36
38
|
|
|
37
39
|
const range = (name: string, value: number, min: number, max: number): number => {
|
|
@@ -78,6 +80,18 @@ const cubePatternsOf = (argv: string[]): string[] => {
|
|
|
78
80
|
return out.filter((p) => p !== "")
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/** The static level cache beside a bake (`<stem>.static`, written by `lecodes level build`) takes the fresh bake's records:
|
|
84
|
+
* a pure join by node path, no host run — the next load rebuilds the statics with the new rects. Nothing to do without one. */
|
|
85
|
+
export const stitchLevelCache = (root: string, staticPath: string, bake: BakeData): void => {
|
|
86
|
+
if (!existsSync(staticPath)) return
|
|
87
|
+
const file = decodeStatics(new Uint8Array(readFileSync(staticPath)))
|
|
88
|
+
if (!file) { warnErr(`${relative(root, staticPath)} is not a level cache of this version — rerun \`lecodes level build\``); return }
|
|
89
|
+
const stitched = stitchStatics(file, bake)
|
|
90
|
+
writeFileSync(staticPath, encodeStatics(stitched))
|
|
91
|
+
const records = stitched.placements.reduce((n, p) => n + p.records.length, 0)
|
|
92
|
+
logErr(c.dim(` level cache ${relative(root, staticPath).replace(/\\/g, "/")}: ${records} record(s) of this bake stitched in (${stitched.placements.length} placements)`))
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
/** The stem of a `.bake` path: `assets/lightmap/level.bake` → `level`. */
|
|
82
96
|
const stemOf = (path: string): string => basename(path).replace(/\.bake$/i, "")
|
|
83
97
|
|
|
@@ -118,77 +132,10 @@ const bakeFileOf = (root: string, flag: string | undefined): string => {
|
|
|
118
132
|
return resolve(root, found[0])
|
|
119
133
|
}
|
|
120
134
|
|
|
121
|
-
// ---- the unwrap pre-step --------------------------------------------------------------------------
|
|
122
|
-
// The bake traces TEXCOORD_1, and a raw import has none: before the host runs, the GLBs the bundle names (asset()
|
|
123
|
-
// compiles to `_creatorFetch.local("<name>")` — the reference list `app build desktop` ships by) whose JSON shows a
|
|
124
|
-
// primitive the unwrap would lay out without one are unwrapped IN PLACE by lecodes-assets (`unwrapGlbFiles`, the
|
|
125
|
-
// `lecodes assets unwrap` code). Nothing is rebuilt after it: the bundle names the files and carries none of their bytes,
|
|
126
|
-
// and the run dir links them only when the host starts. A file that has TEXCOORD_1 is never opened past its JSON.
|
|
127
|
-
|
|
128
|
-
type GltfJson = {
|
|
129
|
-
nodes?: { mesh?: number, skin?: number }[]
|
|
130
|
-
meshes?: { primitives?: { mode?: number, indices?: number, attributes?: Record<string, number> }[] }[]
|
|
131
|
-
accessors?: { count?: number }[]
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** A GLB's JSON chunk — the 20 header bytes and the chunk itself, never the binary one — or null when it is not a GLB. */
|
|
135
|
-
const readGlbJson = (file: string): GltfJson | null => {
|
|
136
|
-
let fd: number | undefined
|
|
137
|
-
try {
|
|
138
|
-
fd = openSync(file, "r")
|
|
139
|
-
const head = Buffer.alloc(20)
|
|
140
|
-
if (readSync(fd, head, 0, 20, 0) !== 20) return null
|
|
141
|
-
if (head.readUInt32LE(0) !== 0x46546c67 || head.readUInt32LE(16) !== 0x4e4f534a) return null // "glTF", then a "JSON" chunk
|
|
142
|
-
const json = Buffer.alloc(head.readUInt32LE(12))
|
|
143
|
-
if (readSync(fd, json, 0, json.length, 20) !== json.length) return null
|
|
144
|
-
return JSON.parse(json.toString("utf8")) as GltfJson
|
|
145
|
-
} catch {
|
|
146
|
-
return null
|
|
147
|
-
} finally {
|
|
148
|
-
if (fd !== undefined) closeSync(fd)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** A primitive the unwrap lays out (a triangle list with a triangle in it, on a mesh no skinned node draws) without
|
|
153
|
-
* TEXCOORD_1 — lecodes-assets' planUnwrap verdict, read off the JSON. */
|
|
154
|
-
const lacksLightmapUv = (g: GltfJson): boolean => {
|
|
155
|
-
const skinned = new Set((g.nodes ?? []).filter((n) => n.skin !== undefined && n.mesh !== undefined).map((n) => n.mesh))
|
|
156
|
-
const count = (accessor: number | undefined): number => (accessor === undefined ? 0 : g.accessors?.[accessor]?.count ?? 0)
|
|
157
|
-
return (g.meshes ?? []).some((mesh, i) => !skinned.has(i) && (mesh.primitives ?? []).some((p) => {
|
|
158
|
-
const pos = count(p.attributes?.POSITION)
|
|
159
|
-
return (p.mode ?? 4) === 4 && pos > 0 && (p.indices === undefined ? pos : count(p.indices)) >= 3 && p.attributes?.TEXCOORD_1 === undefined
|
|
160
|
-
}))
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** The project GLBs the bundle names. */
|
|
164
|
-
const referencedGlbs = (js: string, resources: BundleResource[]): string[] => {
|
|
165
|
-
const wanted = new Set([ ...js.matchAll(/(?:fetchLocal|_creatorFetch\.local)\("([^"]+)"\)/g) ].map((m) => m[1]))
|
|
166
|
-
return [ ...new Set(resources.filter((r) => !r.remote && !r.shader && r.absPath && /\.glb$/i.test(r.name) && wanted.has(r.name)).map((r) => r.absPath!)) ]
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const unwrapReferenced = async (js: string, resources: BundleResource[], root: string, texel: number): Promise<void> => {
|
|
170
|
-
const glbs = referencedGlbs(js, resources)
|
|
171
|
-
const raw = glbs.filter((f) => { const g = readGlbJson(f); return g !== null && lacksLightmapUv(g) })
|
|
172
|
-
if (raw.length === 0) { note(`Lightmap uvs: the ${glbs.length} GLB(s) the app references carry them`); return }
|
|
173
|
-
note(`Unwrapping ${raw.length} of the ${glbs.length} GLB(s) the app references — no lightmap uvs; written in place (--no-unwrap skips)…`)
|
|
174
|
-
const assets = await loadPeer<typeof import("lecodes-assets/cli")>("lecodes-assets", "cli", { for: "lecodes lightmap bake (lightmap uvs)" })
|
|
175
|
-
const t0 = performance.now()
|
|
176
|
-
const rel = (f: string): string => relative(root, f).replace(/\\/g, "/")
|
|
177
|
-
const reports = await assets.unwrapGlbFiles(raw, {
|
|
178
|
-
texel,
|
|
179
|
-
onFile: (r) => {
|
|
180
|
-
if (r.status === "failed") warnErr(`${rel(r.input)}: ${r.summary} — it bakes without lightmap uvs`)
|
|
181
|
-
else logErr(c.dim(` ${r.status === "unwrapped" ? "unwrapped " : ""}${rel(r.input)}: ${r.summary}`))
|
|
182
|
-
},
|
|
183
|
-
})
|
|
184
|
-
const count = (s: string): number => reports.filter((r) => r.status === s).length
|
|
185
|
-
note(`Lightmap uvs: ${count("unwrapped")} file(s) unwrapped${count("skipped") ? `, ${count("skipped")} had nothing to unwrap` : ""}${count("failed") ? `, ${count("failed")} failed` : ""} in ${((performance.now() - t0) / 1000).toFixed(1)} s`)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
135
|
const bake = defineCommand({
|
|
189
136
|
name: "bake",
|
|
190
137
|
summary: "Bake the sun, the sky and the lamps into a lux atlas for the level's statics",
|
|
191
|
-
description: "Runs the app in bake mode in lecodes-bake — the windowless host with the bake built in (no physics, 2D, audio, net or navmesh), downloaded on first use like the desktop host; LECODES_BAKE_EXE points at a local build (hosts/desktop build.ps1 -Bake). The statics are what the app registers with Lightmap.add (a scene file does it for every node no Physics aspect moves), the bake fires at its Lightmap.load call. Needs a GPU with hardware ray tracing (--tracer).\n\nWhere: the level's own bake('<dir>/<stem>.bake') — env: { lightmap: bake('./assets/lightmap/level.bake') } in a scene file, the path relative to that file — names the target; nothing has to exist before the first bake. Writes into <dir>: <stem>.bake (the manifest: it names every file below, and the next compile reads it — nothing to paste into the scene) + <stem>-light[_n].ktx2 (irradiance, BC6H, a page per _n) + <stem>-aux[_n].ktx2 (sky visibility, the directional share and direction of the light) + <stem>-probes.ktx2 (the reflection probes: a cubemap of the level's radiance per probe, prefiltered per roughness; --probe-spacing 0 = none) + <stem>.lgrid (the light grid the movers read; --volume 0 = none) + <stem>-uv1.bin (the packed lightmap uv1 the rects map) + with --split <stem>-direct[_n].ktx2 / <stem>-indirect[_n].ktx2.\n\nLightmap UVs:
|
|
138
|
+
description: "Runs the app in bake mode in lecodes-bake — the windowless host with the bake built in (no physics, 2D, audio, net or navmesh), downloaded on first use like the desktop host; LECODES_BAKE_EXE points at a local build (hosts/desktop build.ps1 -Bake). The statics are what the app registers with Lightmap.add (a scene file does it for every node no Physics aspect moves), the bake fires at its Lightmap.load call. Needs a GPU with hardware ray tracing (--tracer).\n\nWhere: the level's own bake('<dir>/<stem>.bake') — env: { lightmap: bake('./assets/lightmap/level.bake') } in a scene file, the path relative to that file — names the target; nothing has to exist before the first bake. Writes into <dir>: <stem>.bake (the manifest: it names every file below, and the next compile reads it — nothing to paste into the scene) + <stem>-light[_n].ktx2 (irradiance, BC6H, a page per _n) + <stem>-aux[_n].ktx2 (sky visibility, the directional share and direction of the light) + <stem>-probes.ktx2 (the reflection probes: a cubemap of the level's radiance per probe, prefiltered per roughness; --probe-spacing 0 = none) + <stem>.lgrid (the light grid the movers read; --volume 0 = none) + <stem>-uv1.bin (the packed lightmap uv1 the rects map) + with --split <stem>-direct[_n].ktx2 / <stem>-indirect[_n].ktx2.\n\nLightmap UVs: a static without TEXCOORD_1 is unwrapped by the bake itself; its charts land in <stem>-uv1.bin with the split geometry the runtime rebuilds the primitive from, and no GLB of the project is written. Vegetation (foliage: true) is never unwrapped: it reads the ground's light. A GLB that carries lightmap uvs of its own (lecodes assets unwrap) keeps them.",
|
|
192
139
|
flags: {
|
|
193
140
|
out: str("write the files into this folder instead of next to the level's .bake (a second seed for `lightmap diff`)", { value: "<dir>" }),
|
|
194
141
|
name: str("bake the level whose bake() names <stem>.bake — when the entry reaches several", { value: "<stem>" }),
|
|
@@ -219,7 +166,7 @@ const bake = defineCommand({
|
|
|
219
166
|
"probe-layout": str("rooms = every indoor point of the grid slides to the middle of its room and the points of one room merge: one probe per room, far from the walls, its box the room (stations every ~8 m along a room over 12 m), outdoor points stay on the grid; grid = everything stays on the grid. Either way the pages carry which probes each block of texels sees (rays at bake time)", { value: "<rooms|grid>", default: "rooms" }),
|
|
220
167
|
"min-texels": num("instances smaller than this many ATLAS TEXELS along every axis (x --texel: 12 at 5 cm = 0.6 m) get no place in the atlas: they still cast their baked shadow and bounce, their own light is baked where they stand (six sides, no atlas texels) — cartridges, boxes, pipe elbows, a fire extinguisher: a baked shadow does not read on them and their tight charts are where the seams are. 0 = everything bakes; --dry-run lists what falls under it", { default: 12 }),
|
|
221
168
|
"thin-texels": num("ROUND AND NARROW objects leave the atlas like --min-texels: an instance none of whose lightmap charts is wider than this many texels across its short side (at --texel) — a railing's tubes, a cable, a pipe run, blinds, a gun barrel. A strip of 1-3 texels around a tube cannot hold the light's change around it and its uv seam shows as a step (a railing: two texels 5 mm apart on the seam, one lit, one shadowed); the ambient cube baked where it stands is smooth. A flat object has a wide chart somewhere and stays. 0 = off; --dry-run lists what falls under it", { default: 4 }),
|
|
222
|
-
cube: str("BAKE AS CUBES BY NAME: a regular expression (ECMAScript, case-sensitive) searched in every record's key — <node path>@<ordinal>, one record per
|
|
169
|
+
cube: str("BAKE AS CUBES BY NAME: a regular expression (ECMAScript, case-sensitive) searched in every record's key — <node path>@<ordinal>, one record per node primitive of a model, so SM_pipe takes every pipe node and @1$ the second node primitive of each. A record that matches leaves the atlas like a --min-texels / --thin-texels one, whatever its size or charts: an occluder whose own light is baked where it stands as ambient cubes (in slabs along a long one, --object-slab) — a pipe run, a fence, a prop whose lightmap seams show. Repeat the flag (--cube SM_pipe --cube SM_fence) or join with | in one (--cube \"SM_pipe|SM_fence\", quoted for the shell); a pattern that starts with - goes as --cube=<regex>. The level's bake() option cube adds its own patterns, a node's lightmap: 'cube' marks one node by hand; --dry-run lists what matches", { value: "<regex>" }),
|
|
223
170
|
"object-slab": num("A LONG object the atlas left out (--min-texels, --thin-texels, --cube) takes its light in SLABS of this many metres along the longest axis of its bounds — round(length / this) ambient cubes, 16 at most — a 6 m pipe run, a railing along a wall: one end under a lamp, the other in the dark, where one cube gave all of it the mean. The shader blends the two slabs around each pixel along that axis. 0 = one cube an object", { value: "<m>", default: 1 }),
|
|
224
171
|
"emissive-nits": num("EMISSIVE SURFACES LIGHT THE LEVEL: cd/m² of emission 1.0 (emissive factor x strength x map) — every lit region of an emissive surface (a panel drawn in a ceiling's texture, a screen, a sign) bakes as a rectangle lamp. Default: what the level's camera makes of 1.0, 1.2 x 2^EV100, so a panel lights as brightly as it looks. 0 = emissive surfaces light nothing"),
|
|
225
172
|
"detail-texels": num("A SMALL SURFACE GETS A FINER TEXEL: an instance whose surface is under this many atlas texels (area / texel²) is laid out denser by sqrt(this / its texels), 3x at most — a pipe of 1 m is ~300 texels at 5 cm, its charts a few texels wide and every chart edge a step; at 2000 it bakes at ~2 cm. 0 = off", { default: 2000 }),
|
|
@@ -233,7 +180,6 @@ const bake = defineCommand({
|
|
|
233
180
|
height: num("the bake host's virtual viewport height (default: app.json desktop.window, else 720)"),
|
|
234
181
|
renderer: str("ignored: the bake host is one binary (lecodes-bake, GL shaders on Filament's NOOP backend); kept so older scripts still parse", { value: "<gl|vulkan>" }),
|
|
235
182
|
logs: bool("forward the host's output"),
|
|
236
|
-
"no-unwrap": bool("leave the referenced GLBs as they are: no lightmap unwrap of those without TEXCOORD_1 (they bake without lightmap uvs)"),
|
|
237
183
|
},
|
|
238
184
|
examples: [
|
|
239
185
|
"lecodes lightmap bake",
|
|
@@ -302,6 +248,7 @@ const bake = defineCommand({
|
|
|
302
248
|
const entryPath = "/.lightmap-entry.ts"
|
|
303
249
|
const entryText = `globalThis.__lecodesLightmap = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
|
|
304
250
|
|
|
251
|
+
const tCommand = Date.now()
|
|
305
252
|
const desktopRenderer = "gl" as const // lecodes-bake is the GL tree on Filament's NOOP backend: `_creator.backend` "desktop" fetches the GL shaders
|
|
306
253
|
const { js, resources, bakes } = await buildBundle(bundleOptions(flags), {
|
|
307
254
|
localAssets: true, desktopRenderer,
|
|
@@ -315,7 +262,6 @@ const bake = defineCommand({
|
|
|
315
262
|
mkdirSync(outDir, { recursive: true })
|
|
316
263
|
if (debugDir) mkdirSync(debugDir, { recursive: true })
|
|
317
264
|
note(`Target: ${relative(root, join(outDir, `${stem}.bake`)).replace(/\\/g, "/")}${target.exists ? "" : " (the first bake: nothing there yet)"}${flags.out ? " (--out)" : ""}`)
|
|
318
|
-
if (!flags["no-unwrap"]) await unwrapReferenced(js, resources, root, cfg.texel)
|
|
319
265
|
|
|
320
266
|
// The host writes the bake itself; the PNG the render driver insists on goes to a scratch dir.
|
|
321
267
|
const scratch = mkdtempSync(join(tmpdir(), "lecodes-lightmap-"))
|
|
@@ -324,11 +270,18 @@ const bake = defineCommand({
|
|
|
324
270
|
note(`Baking up to ${cfg.pages} × ${cfg.size}² at ${(cfg.texel * 100).toFixed(1)} cm texels (${cfg.sunRays} sun + ${cfg.skyRays} sky + ${cfg.lightRays} lamp rays${bounce}) → ${relative(root, outDir).split(sep).join("/") || "."}/${stem}.*…`)
|
|
325
271
|
let output = ""
|
|
326
272
|
if (flags["dry-run"]) process.env.CBK_LAYOUT_ONLY = "1" // the host inherits it: creator-bake stops after the layout
|
|
273
|
+
// the wall clock: when the host's marker lines arrive (the compile is reported by the compile itself)
|
|
274
|
+
const tCompiled = Date.now()
|
|
275
|
+
const marks: Record<string, number> = {}
|
|
276
|
+
const MARKS: [RegExp, string][] = [[/initial\.js evaluated/, "up"], [/\[glb\] loads:/, "glb"], [/\[lightmap\] describing/, "level"], [/\[lightmap\] described in/, "described"], [/\[lightmap\] (done|error)\s*$/m, "done"]]
|
|
277
|
+
// the adapter's own `[lightmap] done` line ends the run - not creator-bake's "done: N page(s)…" summary, which comes first
|
|
278
|
+
const ended = (text: string) => /\[lightmap\] (done|error)\s*$/m.test(text) || /\[lightmap\] error:/.test(text)
|
|
327
279
|
try {
|
|
328
280
|
const timeoutMs = flags.timeout || 24 * 3600 * 1000
|
|
329
281
|
const res = await runDesktopRender(js, resources, {
|
|
330
282
|
host: "bake", renderer: desktopRenderer, width, height, frames, outPng, logs: flags.logs, timeoutMs,
|
|
331
|
-
|
|
283
|
+
onChunk: (chunk) => { for (const [re, key] of MARKS) if (!(key in marks) && re.test(chunk)) marks[key] = Date.now() },
|
|
284
|
+
stopOnLine: ended,
|
|
332
285
|
})
|
|
333
286
|
output = res.output
|
|
334
287
|
} catch (e) {
|
|
@@ -345,6 +298,17 @@ const bake = defineCommand({
|
|
|
345
298
|
if (text === "done") continue
|
|
346
299
|
logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : text.startsWith("warning") ? c.yellow(` ${text}`) : c.dim(` ${text}`))
|
|
347
300
|
}
|
|
301
|
+
{
|
|
302
|
+
// the command's wall clock, stage by stage (each from the previous mark; a stage whose mark never came is left out)
|
|
303
|
+
const tEnd = Date.now()
|
|
304
|
+
const secs = (ms: number) => `${(ms / 1000).toFixed(1)} s`
|
|
305
|
+
const stages: [string, number | undefined, number | undefined][] = [
|
|
306
|
+
["compile", tCommand, tCompiled], ["host up", tCompiled, marks.up], ["glb loaded", marks.up, marks.glb], ["level built (js)", marks.glb, marks.level],
|
|
307
|
+
["described", marks.level, marks.described], ["creator-bake", marks.described, marks.done], ["host stopped + files", marks.done, tEnd],
|
|
308
|
+
]
|
|
309
|
+
const parts = stages.filter(([, a, b]) => a !== undefined && b !== undefined).map(([name, a, b]) => `${name} ${secs(b! - a!)}`)
|
|
310
|
+
logErr(c.dim(` wall clock ${secs(tEnd - tCommand)}: ${parts.join(", ")}`))
|
|
311
|
+
}
|
|
348
312
|
if (flags["dry-run"] && /\[lightmap\] done/.test(output)) {
|
|
349
313
|
for (const l of lines) if (/atlas \d+ x|packed per file|largest rect|do not fit|fill:|--cube|lightmap: 'cube'/.test(l)) logErr(l.replace(/^\[lightmap\] /, " "))
|
|
350
314
|
logErr(c.green("✓ layout only — nothing written"))
|
|
@@ -361,6 +325,7 @@ const bake = defineCommand({
|
|
|
361
325
|
? ` written to --out: the level's bake() still reads ${target.path}`
|
|
362
326
|
: ` the manifest names them: the next compile of ${entryFile} stages them (bake('…') in the scene reads it)`))
|
|
363
327
|
if (debugDir) logErr(c.dim(` debug layers: ${relative(root, debugDir)} (lecodes lightmap inspect --bake ${relative(root, join(outDir, `${stem}.bake`)).replace(/\\/g, "/")} --debug-dir ${relative(root, debugDir).replace(/\\/g, "/")})`))
|
|
328
|
+
stitchLevelCache(root, join(outDir, `${stem}.static`), data)
|
|
364
329
|
return
|
|
365
330
|
}
|
|
366
331
|
if (/\[lightmap\] error: this host has no bake module/.test(output)) { // console.error: the host prefixes the line ("[ERROR] [lightmap] …")
|
package/src/commands/navmesh.ts
CHANGED
|
@@ -45,7 +45,7 @@ type BakeConfigLine = {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/** The first `*.scene.ts` under src/ (or the root) that declares `navmesh:` names the output by default. */
|
|
48
|
-
const
|
|
48
|
+
export const guessNavmeshName = (root: string): string | null => {
|
|
49
49
|
const dirs = [ join(root, "src"), root ]
|
|
50
50
|
for (const dir of dirs) {
|
|
51
51
|
if (!existsSync(dir)) continue
|
|
@@ -63,7 +63,7 @@ const guessName = (root: string): string | null => {
|
|
|
63
63
|
|
|
64
64
|
/** An empty LNAV (header only, one dummy tile slot) so the compiler can stage the file before the
|
|
65
65
|
* first bake; NavMesh.load reads `stats.polys === 0` as "placeholder — rebake". */
|
|
66
|
-
const placeholderLnav = (): Buffer => {
|
|
66
|
+
export const placeholderLnav = (): Buffer => {
|
|
67
67
|
const json = Buffer.from(JSON.stringify({ version: 1, placeholder: true, stats: { tiles: 0, polys: 0 } }), "utf8")
|
|
68
68
|
const jsonPadded = Buffer.alloc(Math.ceil(json.length / 4) * 4)
|
|
69
69
|
json.copy(jsonPadded)
|
|
@@ -88,15 +88,8 @@ const writeObj = (path: string, tris: Float32Array): void => {
|
|
|
88
88
|
writeFileSync(path, lines.join("\n") + "\n")
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
summary: "Build the level's navigation mesh for NavAgent / NavMesh",
|
|
94
|
-
description: "The desktop host dumps every STATIC body's collision triangles at the scene's env.navmesh (or NavMesh.load) point, then Recast builds the tiled mesh here → <out>/<name>.navmesh. The agent size defaults to what the scene declares. Needs a desktop host with the geometry dump (LECODES_DESKTOP_EXE for a local build).",
|
|
95
|
-
flags: {
|
|
96
|
-
out: str("where it lands", { value: "<dir>", default: "assets/nav" }),
|
|
97
|
-
name: str("file name (default: the scene file that declares navmesh:, else scene)", { value: "<n>" }),
|
|
98
|
-
...bundleFlags,
|
|
99
|
-
entry: str("the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load", { value: "<file>", default: "main.ts" }),
|
|
91
|
+
/** The Recast parameters `navmesh bake` and `level build` share. */
|
|
92
|
+
export const NAVMESH_FLAGS = {
|
|
100
93
|
radius: num("agent radius (m; default: the scene's)"),
|
|
101
94
|
height: num("agent height (m; default: the scene's)"),
|
|
102
95
|
climb: num("agent max step height (m; default: the scene's)"),
|
|
@@ -112,63 +105,17 @@ const bake = defineCommand({
|
|
|
112
105
|
"detail-error": num("detail mesh max error (voxels)", { default: 1 }),
|
|
113
106
|
obj: bool("also write <name>.navmesh.obj to inspect the polygons"),
|
|
114
107
|
"keep-geometry": bool("keep the host's <name>.navgeo dump"),
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
examples: ["lecodes navmesh bake", "lecodes navmesh bake --name yard --cell 0.2 --obj --logs"],
|
|
122
|
-
run: async ({ flags }) => {
|
|
123
|
-
const { root } = localProject()
|
|
124
|
-
const outDir = resolve(root, flags.out)
|
|
125
|
-
const entryFile = flags.entry
|
|
126
|
-
if (!existsSync(resolve(root, entryFile))) {
|
|
127
|
-
throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load).`)
|
|
128
|
-
}
|
|
129
|
-
const name = flags.name ?? guessName(root) ?? "scene"
|
|
130
|
-
const frames = range("frames", flags.frames, 900, 30, 100000)
|
|
131
|
-
const win = readAppConfigAt(root)?.desktop?.window
|
|
132
|
-
const width = flags.width || win?.width || 1280
|
|
133
|
-
const height = win?.height || 720
|
|
134
|
-
const logs = flags.logs
|
|
135
|
-
|
|
136
|
-
// The navmesh file must exist BEFORE the compile: the scene references it through asset(), and a
|
|
137
|
-
// missing asset is a broken bundle. The first bake starts from a placeholder the real file overwrites.
|
|
138
|
-
mkdirSync(outDir, { recursive: true })
|
|
139
|
-
const navmeshPath = join(outDir, `${name}.navmesh`)
|
|
140
|
-
if (!existsSync(navmeshPath)) writeFileSync(navmeshPath, placeholderLnav())
|
|
141
|
-
|
|
142
|
-
const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
|
|
143
|
-
const entryPath = "/.navmesh-entry.ts"
|
|
144
|
-
const cfg = { outDir: outDir.replace(/\\/g, "/"), name }
|
|
145
|
-
const entryText = `globalThis.__lecodesNavmesh = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
|
|
146
|
-
|
|
147
|
-
const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
|
|
148
|
-
const { js, resources } = await buildBundle(bundleOptions(flags), {
|
|
149
|
-
localAssets: true, desktopRenderer,
|
|
150
|
-
extraEntries: [{ path: entryPath, type: "text", text: entryText }],
|
|
151
|
-
entryOverride: entryPath,
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
const scratch = mkdtempSync(join(tmpdir(), "lecodes-navmesh-"))
|
|
155
|
-
const outPng = join(scratch, "frame.png")
|
|
156
|
-
note(`Dumping the level's static collision geometry through the desktop host → ${relative(root, outDir) || "."}/${name}.navgeo…`)
|
|
157
|
-
let output = ""
|
|
158
|
-
try {
|
|
159
|
-
const res = await runDesktopRender(js, resources, {
|
|
160
|
-
renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs: flags.timeout || 600000,
|
|
161
|
-
stopOnLine: (chunk) => chunk.includes("[navmesh] done") || chunk.includes("[navmesh] error"),
|
|
162
|
-
})
|
|
163
|
-
output = res.output
|
|
164
|
-
} catch (e) {
|
|
165
|
-
const msg = e instanceof Error ? e.message : String(e)
|
|
166
|
-
if (!/\[navmesh\] done/.test(msg)) throw e
|
|
167
|
-
output = msg
|
|
168
|
-
} finally {
|
|
169
|
-
rmSync(scratch, { recursive: true, force: true })
|
|
170
|
-
}
|
|
108
|
+
}
|
|
109
|
+
export type NavmeshFlags = {
|
|
110
|
+
radius?: number, height?: number, climb?: number, slope?: number, cell?: number, "cell-height"?: number, tile?: number
|
|
111
|
+
"region-min"?: number, "region-merge"?: number, "edge-max"?: number, "edge-error"?: number, detail?: number, "detail-error"?: number
|
|
112
|
+
obj?: boolean, "keep-geometry"?: boolean
|
|
113
|
+
}
|
|
171
114
|
|
|
115
|
+
/** From the host's output to `<outDir>/<name>.navmesh`: the `[navmesh]` lines read, the geometry dump handed to Recast.
|
|
116
|
+
* Shared by `navmesh bake` and `level build` (one host run serves both the navmesh and the static level cache). */
|
|
117
|
+
export const finishNavmesh = async (root: string, outDir: string, name: string, output: string, flags: NavmeshFlags): Promise<void> => {
|
|
118
|
+
const navmeshPath = join(outDir, `${name}.navmesh`)
|
|
172
119
|
// the markers are console.log lines from the SDK — the host prefixes them ("[DEBUG] [navmesh] …")
|
|
173
120
|
const lines = output.split(/\r?\n/).filter((l) => l.includes("[navmesh]")).map((l) => l.slice(l.indexOf("[navmesh]")))
|
|
174
121
|
let config: BakeConfigLine | null = null
|
|
@@ -245,6 +192,76 @@ const bake = defineCommand({
|
|
|
245
192
|
if (!config.data) {
|
|
246
193
|
logErr(c.dim(` load it with env.navmesh: { data: asset('<rel>/${basename(navmeshPath)}') } in the scene file, or NavMesh.load(scene, { data: asset(...) })`))
|
|
247
194
|
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const bake = defineCommand({
|
|
198
|
+
name: "bake",
|
|
199
|
+
summary: "Build the level's navigation mesh for NavAgent / NavMesh",
|
|
200
|
+
description: "The desktop host dumps every STATIC body's collision triangles at the scene's env.navmesh (or NavMesh.load) point, then Recast builds the tiled mesh here → <out>/<name>.navmesh. The agent size defaults to what the scene declares. Needs a desktop host with the geometry dump (LECODES_DESKTOP_EXE for a local build).",
|
|
201
|
+
flags: {
|
|
202
|
+
out: str("where it lands", { value: "<dir>", default: "assets/nav" }),
|
|
203
|
+
name: str("file name (default: the scene file that declares navmesh:, else scene)", { value: "<n>" }),
|
|
204
|
+
...bundleFlags,
|
|
205
|
+
entry: str("the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load", { value: "<file>", default: "main.ts" }),
|
|
206
|
+
...NAVMESH_FLAGS,
|
|
207
|
+
frames: num("host frames to run before giving up — load headroom", { default: 900 }),
|
|
208
|
+
timeout: num("host time cap (ms)", { default: 600000 }),
|
|
209
|
+
width: num("host window width (default: app.json desktop.window, else 1280)"),
|
|
210
|
+
renderer: str("desktop host + shader variant (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
|
|
211
|
+
logs: bool("forward the host's output"),
|
|
212
|
+
},
|
|
213
|
+
examples: ["lecodes navmesh bake", "lecodes navmesh bake --name yard --cell 0.2 --obj --logs"],
|
|
214
|
+
run: async ({ flags }) => {
|
|
215
|
+
const { root } = localProject()
|
|
216
|
+
const outDir = resolve(root, flags.out)
|
|
217
|
+
const entryFile = flags.entry
|
|
218
|
+
if (!existsSync(resolve(root, entryFile))) {
|
|
219
|
+
throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the level: a scene file with env.navmesh, or code calling NavMesh.load).`)
|
|
220
|
+
}
|
|
221
|
+
const name = flags.name ?? guessNavmeshName(root) ?? "scene"
|
|
222
|
+
const frames = range("frames", flags.frames, 900, 30, 100000)
|
|
223
|
+
const win = readAppConfigAt(root)?.desktop?.window
|
|
224
|
+
const width = flags.width || win?.width || 1280
|
|
225
|
+
const height = win?.height || 720
|
|
226
|
+
const logs = flags.logs
|
|
227
|
+
|
|
228
|
+
// The navmesh file must exist BEFORE the compile: the scene references it through asset(), and a
|
|
229
|
+
// missing asset is a broken bundle. The first bake starts from a placeholder the real file overwrites.
|
|
230
|
+
mkdirSync(outDir, { recursive: true })
|
|
231
|
+
const navmeshPath = join(outDir, `${name}.navmesh`)
|
|
232
|
+
if (!existsSync(navmeshPath)) writeFileSync(navmeshPath, placeholderLnav())
|
|
233
|
+
|
|
234
|
+
const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
|
|
235
|
+
const entryPath = "/.navmesh-entry.ts"
|
|
236
|
+
const cfg = { outDir: outDir.replace(/\\/g, "/"), name }
|
|
237
|
+
const entryText = `globalThis.__lecodesNavmesh = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
|
|
238
|
+
|
|
239
|
+
const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
|
|
240
|
+
const { js, resources } = await buildBundle(bundleOptions(flags), {
|
|
241
|
+
localAssets: true, desktopRenderer,
|
|
242
|
+
extraEntries: [{ path: entryPath, type: "text", text: entryText }],
|
|
243
|
+
entryOverride: entryPath,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
const scratch = mkdtempSync(join(tmpdir(), "lecodes-navmesh-"))
|
|
247
|
+
const outPng = join(scratch, "frame.png")
|
|
248
|
+
note(`Dumping the level's static collision geometry through the desktop host → ${relative(root, outDir) || "."}/${name}.navgeo…`)
|
|
249
|
+
let output = ""
|
|
250
|
+
try {
|
|
251
|
+
const res = await runDesktopRender(js, resources, {
|
|
252
|
+
renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs: flags.timeout || 600000,
|
|
253
|
+
stopOnLine: (chunk) => chunk.includes("[navmesh] done") || chunk.includes("[navmesh] error"),
|
|
254
|
+
})
|
|
255
|
+
output = res.output
|
|
256
|
+
} catch (e) {
|
|
257
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
258
|
+
if (!/\[navmesh\] done/.test(msg)) throw e
|
|
259
|
+
output = msg
|
|
260
|
+
} finally {
|
|
261
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await finishNavmesh(root, outDir, name, output, flags)
|
|
248
265
|
},
|
|
249
266
|
})
|
|
250
267
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import { join, relative } from "node:path"
|
|
3
|
+
import { ENV_NAME, isPublicEnvName, isServerPath, scanEnvReads } from "lecodes-sdk/compile"
|
|
4
|
+
import { CliError, UsageError, bool, c, defineCommand, info, log, note, promptHidden, relativeTime, str, success, table } from "../cli"
|
|
5
|
+
import { listSecrets, removeSecret, setSecret } from "../platform/api"
|
|
6
|
+
import { readAppEnv } from "../project/env"
|
|
7
|
+
import { linkedProject } from "./shared"
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* `lecodes secrets` — the project's keys on le.codes (Project Settings → Keys), what server code reads
|
|
11
|
+
* as `env.NAME` and the app as `env.PUBLIC_NAME`. On this machine the same keys live in the project's
|
|
12
|
+
* `.env`; this command carries them to the platform (never back: a value is set, replaced or removed,
|
|
13
|
+
* the platform shows no value). `list` is the one place both sides are seen together with what the
|
|
14
|
+
* code reads, so a missing key is found before a build says so.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const SKIP_DIRS = new Set(["node_modules", ".lecodes", ".git", "dist", "design"])
|
|
18
|
+
|
|
19
|
+
/** Every `env.NAME` read in the project's code: name → "server" | "app" | "both". */
|
|
20
|
+
export const readsOf = (root: string): Map<string, "server" | "app" | "both"> => {
|
|
21
|
+
const out = new Map<string, "server" | "app" | "both">()
|
|
22
|
+
const walk = (dir: string) => {
|
|
23
|
+
for (const entry of readdirSync(dir)) {
|
|
24
|
+
if (entry.startsWith("_") || entry.startsWith(".") || SKIP_DIRS.has(entry)) continue
|
|
25
|
+
const path = join(dir, entry)
|
|
26
|
+
let isDir = false
|
|
27
|
+
try { isDir = statSync(path).isDirectory() } catch { continue }
|
|
28
|
+
if (isDir) { walk(path); continue }
|
|
29
|
+
if (!/\.[tj]s$/.test(entry) || entry.endsWith(".d.ts")) continue
|
|
30
|
+
const side = isServerPath(path) ? "server" : "app"
|
|
31
|
+
let text = ""
|
|
32
|
+
try { text = readFileSync(path, "utf8") } catch { continue }
|
|
33
|
+
for (const name of scanEnvReads(text)) {
|
|
34
|
+
const had = out.get(name)
|
|
35
|
+
out.set(name, had && had !== side ? "both" : side)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
walk(root)
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const checkName = (name: string | undefined): string => {
|
|
44
|
+
if (!name) throw new UsageError("A key name is needed: lecodes secrets set NAME")
|
|
45
|
+
if (!ENV_NAME.test(name)) throw new CliError(`"${name}" is not a key name — capital letters, digits and underscores (STRIPE_KEY, PUBLIC_MAPS_KEY)`)
|
|
46
|
+
return name
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const list = defineCommand({
|
|
50
|
+
name: "list",
|
|
51
|
+
summary: "The project's keys: on le.codes, in .env, and what the code reads",
|
|
52
|
+
run: async () => {
|
|
53
|
+
const { root, manifest, apiUrl, token } = linkedProject()
|
|
54
|
+
// the platform lists a name the code reads before anyone set it (`set: false`): here the code is scanned on disk
|
|
55
|
+
const remote = new Map((await listSecrets(apiUrl, token, manifest.uuid)).filter(s => s.set !== false).map(s => [s.name, s]))
|
|
56
|
+
const local = readAppEnv(root)
|
|
57
|
+
const reads = readsOf(root)
|
|
58
|
+
const names = [...new Set([...remote.keys(), ...Object.keys(local), ...reads.keys()])].sort()
|
|
59
|
+
if (!names.length) { info("No keys: none on le.codes, none in .env, none read by the code."); return }
|
|
60
|
+
const rows = names.map(name => {
|
|
61
|
+
const r = remote.get(name)
|
|
62
|
+
const by = reads.get(name)
|
|
63
|
+
const flag = by && !r ? c.yellow("missing on le.codes") : by && !(name in local) ? c.dim("not in .env") : ""
|
|
64
|
+
return [name, r ? `le.codes ${c.dim(relativeTime(r.updatedAt))}` : c.dim("—"), name in local ? ".env" : c.dim("—"), by ? `read by ${by === "both" ? "server + app" : by}` : c.dim("not read"), flag]
|
|
65
|
+
})
|
|
66
|
+
table(rows)
|
|
67
|
+
const secretsInApp = names.filter(n => !isPublicEnvName(n) && (reads.get(n) === "app" || reads.get(n) === "both"))
|
|
68
|
+
if (secretsInApp.length) log(c.red(` ${secretsInApp.join(", ")}: read by app code — a key that is not PUBLIC_* is a secret; read it in a *.server.ts file`))
|
|
69
|
+
note("A value never comes back from le.codes: `secrets set NAME --from-env` sends the .env one, `secrets push` sends them all.")
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const set = defineCommand({
|
|
74
|
+
name: "set",
|
|
75
|
+
summary: "Set (or replace) a key on le.codes",
|
|
76
|
+
usage: "<NAME>",
|
|
77
|
+
flags: {
|
|
78
|
+
"from-env": bool("Take the value from the project's .env instead of asking"),
|
|
79
|
+
value: str("The value itself (visible in the shell history — prefer the prompt or --from-env)", { value: "<value>", optional: true }),
|
|
80
|
+
},
|
|
81
|
+
description: "The value is asked for without echo unless --from-env (the .env line of that name) or --value gives it. Only the project's owner may set a key; a developer sees the names (`list`).",
|
|
82
|
+
run: async ({ args, flags }) => {
|
|
83
|
+
const name = checkName(args[0])
|
|
84
|
+
const { root, manifest, apiUrl, token } = linkedProject()
|
|
85
|
+
let value = flags.value
|
|
86
|
+
if (flags["from-env"]) {
|
|
87
|
+
const local = readAppEnv(root)
|
|
88
|
+
if (!(name in local)) throw new CliError(`${name} is not in ${relative(process.cwd(), join(root, ".env")) || ".env"} — add a line \`${name}=value\` first`)
|
|
89
|
+
value = local[name]
|
|
90
|
+
}
|
|
91
|
+
if (value === undefined) {
|
|
92
|
+
if (!process.stdin.isTTY) throw new CliError("No terminal to ask the value in: pass --from-env or --value")
|
|
93
|
+
value = await promptHidden(`Value for ${name}`)
|
|
94
|
+
}
|
|
95
|
+
if (!value) throw new CliError("An empty value — nothing set")
|
|
96
|
+
await setSecret(apiUrl, token, manifest.uuid, name, value)
|
|
97
|
+
success(`${name} set on le.codes — ${isPublicEnvName(name) ? "the app gets it with its next build" : "the server gets it with the next build"}`)
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const remove = defineCommand({
|
|
102
|
+
name: "remove",
|
|
103
|
+
summary: "Remove a key from le.codes (the .env line stays)",
|
|
104
|
+
usage: "<NAME>",
|
|
105
|
+
aliases: ["rm", "delete"],
|
|
106
|
+
run: async ({ args }) => {
|
|
107
|
+
const name = checkName(args[0])
|
|
108
|
+
const { manifest, apiUrl, token } = linkedProject()
|
|
109
|
+
await removeSecret(apiUrl, token, manifest.uuid, name)
|
|
110
|
+
success(`${name} removed from le.codes`)
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const push = defineCommand({
|
|
115
|
+
name: "push",
|
|
116
|
+
summary: "Send every key of the project's .env to le.codes",
|
|
117
|
+
usage: "[NAME...]",
|
|
118
|
+
description: "Every app key of .env (the capital names that are not the CLI's LECODES_* / CREATOR_*), or only the names given. A key already on le.codes is replaced.",
|
|
119
|
+
run: async ({ args }) => {
|
|
120
|
+
const { root, manifest, apiUrl, token } = linkedProject()
|
|
121
|
+
const local = readAppEnv(root)
|
|
122
|
+
const names = args.length ? args.map(checkName) : Object.keys(local).sort()
|
|
123
|
+
if (!names.length) { info("Nothing to send: .env has no app keys (a capital NAME=value line)."); return }
|
|
124
|
+
for (const name of names) {
|
|
125
|
+
if (!(name in local)) throw new CliError(`${name} is not in .env`)
|
|
126
|
+
await setSecret(apiUrl, token, manifest.uuid, name, local[name])
|
|
127
|
+
log(` ${c.green("set")} ${name}`)
|
|
128
|
+
}
|
|
129
|
+
success(`${names.length} key${names.length === 1 ? "" : "s"} on le.codes — picked up with the next build`)
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
export default defineCommand({
|
|
134
|
+
name: "secrets",
|
|
135
|
+
summary: "The project's keys on le.codes (env.NAME in code)",
|
|
136
|
+
aliases: ["keys"],
|
|
137
|
+
description: "A key — a token, a client secret, a maps key — is read in code as env.NAME: every key in *.server.ts files, only PUBLIC_* ones in the app (its bundle is public). On this machine the keys live in the project's .env (NAME=value, never pushed); on le.codes in Project Settings → Keys, which these commands edit. A value never comes back from le.codes.\n\nNames are capital letters, digits and underscores. `lecodes dev` and the builds say which key the code reads and does not have.",
|
|
138
|
+
commands: [list, set, remove, push],
|
|
139
|
+
examples: [
|
|
140
|
+
"lecodes secrets list",
|
|
141
|
+
"lecodes secrets set STRIPE_KEY # asks for the value, no echo",
|
|
142
|
+
"lecodes secrets set STRIPE_KEY --from-env # the .env line",
|
|
143
|
+
"lecodes secrets push # every .env key",
|
|
144
|
+
"lecodes secrets remove STRIPE_KEY",
|
|
145
|
+
],
|
|
146
|
+
run: (ctx) => list.run!(ctx),
|
|
147
|
+
})
|