lecodes-cli 0.18.2 → 0.19.2
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 +1866 -301
- package/package.json +14 -12
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/src/api.ts +302 -0
- package/src/browserAuth.ts +87 -0
- package/src/cmgenTool.ts +104 -0
- package/src/commands/app.ts +891 -0
- package/src/commands/appAndroid.ts +603 -0
- package/src/commands/appDesktop.ts +326 -0
- package/src/commands/appDesktopMac.ts +470 -0
- package/src/commands/appIcon.ts +187 -0
- package/src/commands/appShared.ts +448 -0
- package/src/commands/appTemplates.ts +883 -0
- package/src/commands/appTemplatesAndroid.ts +599 -0
- package/src/commands/appTemplatesGradlew.ts +9 -0
- package/src/commands/assets.ts +28 -0
- package/src/commands/clone.ts +60 -0
- package/src/commands/compile.ts +253 -0
- package/src/commands/create.ts +59 -0
- package/src/commands/design.ts +863 -0
- package/src/commands/designTemplates.ts +11 -0
- package/src/commands/desktop.ts +122 -0
- package/src/commands/dev.ts +214 -0
- package/src/commands/diff.ts +59 -0
- package/src/commands/init.ts +191 -0
- package/src/commands/install.ts +198 -0
- package/src/commands/lightmap.ts +290 -0
- package/src/commands/link.ts +147 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/navmesh.ts +225 -0
- package/src/commands/pn.ts +305 -0
- package/src/commands/projectTemplates.ts +129 -0
- package/src/commands/pull.ts +109 -0
- package/src/commands/push.ts +173 -0
- package/src/commands/render.ts +414 -0
- package/src/commands/scene.ts +148 -0
- package/src/commands/shaders.ts +157 -0
- package/src/commands/status.ts +29 -0
- package/src/commands/test.ts +370 -0
- package/src/commands/thumbs.ts +178 -0
- package/src/commands/types.ts +87 -0
- package/src/commands/update.ts +190 -0
- package/src/compile/assetIcons.ts +214 -0
- package/src/compile/collect.ts +46 -0
- package/src/compile/collectLocal.ts +37 -0
- package/src/compile/designCompile.ts +104 -0
- package/src/compile/fonts.ts +156 -0
- package/src/compile/headlessBundle.ts +129 -0
- package/src/compile/nativeStack.ts +21 -0
- package/src/compile/projectCompile.ts +109 -0
- package/src/compile/sceneCompile.ts +113 -0
- package/src/compile/screenEntry.ts +127 -0
- package/src/compile/shaders.ts +245 -0
- package/src/config.ts +42 -0
- package/src/designMeta.ts +35 -0
- package/src/desktopRenderer.ts +532 -0
- package/src/desktopScript.ts +276 -0
- package/src/dev/clientTemplates.ts +160 -0
- package/src/dev/devServer.ts +292 -0
- package/src/dev/wsServer.ts +144 -0
- package/src/distRoot.ts +20 -0
- package/src/ignore.ts +163 -0
- package/src/index.ts +491 -0
- package/src/lecodes-3d-editor.d.ts +41 -0
- package/src/lecodes-assets.d.ts +7 -0
- package/src/lecodes-design.d.ts +191 -0
- package/src/lecodes-renderer.d.ts +131 -0
- package/src/localFiles.ts +144 -0
- package/src/manifest.ts +46 -0
- package/src/matcTool.ts +137 -0
- package/src/peers.ts +40 -0
- package/src/project.ts +61 -0
- package/src/projectEnv.ts +94 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/releases.ts +125 -0
- package/src/serverDiff.ts +78 -0
- package/src/textDiff.ts +103 -0
- package/src/types.ts +0 -0
- package/src/util.ts +146 -0
- package/runtime/sdk/animate/animate.ts +0 -238
- package/runtime/sdk/animate/bezier.ts +0 -138
- package/runtime/sdk/animate/easings.ts +0 -126
- package/runtime/sdk/canvas/Canvas.ts +0 -305
- package/runtime/sdk/core/Aspect.ts +0 -512
- package/runtime/sdk/core/InspectorUI.ts +0 -212
- package/runtime/sdk/core/color.ts +0 -66
- package/runtime/sdk/core/compWrite.ts +0 -42
- package/runtime/sdk/core/events.ts +0 -38
- package/runtime/sdk/core/fields.ts +0 -120
- package/runtime/sdk/core/registry.ts +0 -23
- package/runtime/sdk/core/signals.ts +0 -277
- package/runtime/sdk/core/time.ts +0 -81
- package/runtime/sdk/g2/Camera2D.ts +0 -40
- package/runtime/sdk/g2/CharacterController2D.ts +0 -276
- package/runtime/sdk/g2/Node2D.ts +0 -267
- package/runtime/sdk/g2/OneWay2D.ts +0 -66
- package/runtime/sdk/g2/Physics2D.ts +0 -346
- package/runtime/sdk/g2/Scene2D.ts +0 -209
- package/runtime/sdk/g2/Shape2D.ts +0 -259
- package/runtime/sdk/g2/Sprite.ts +0 -89
- package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
- package/runtime/sdk/g2/SpriteSheet.ts +0 -166
- package/runtime/sdk/g2/Texture2D.ts +0 -47
- package/runtime/sdk/g2/Tilemap.ts +0 -41
- package/runtime/sdk/g2/Tileset.ts +0 -71
- package/runtime/sdk/g2/Trigger2D.ts +0 -77
- package/runtime/sdk/g2/autotile.ts +0 -433
- package/runtime/sdk/g2/cells.ts +0 -91
- package/runtime/sdk/g2/defineScene2d.ts +0 -381
- package/runtime/sdk/g2/groups2d.ts +0 -106
- package/runtime/sdk/g2/loop.ts +0 -50
- package/runtime/sdk/g2/scenarios2d.ts +0 -69
- package/runtime/sdk/g2/touch.ts +0 -83
- package/runtime/sdk/gl/Camera.ts +0 -160
- package/runtime/sdk/gl/CameraPlace.ts +0 -52
- package/runtime/sdk/gl/CharacterController.ts +0 -238
- package/runtime/sdk/gl/Gearbox.ts +0 -212
- package/runtime/sdk/gl/Geometry.ts +0 -279
- package/runtime/sdk/gl/IK.ts +0 -193
- package/runtime/sdk/gl/InstancedMesh.ts +0 -132
- package/runtime/sdk/gl/Light.ts +0 -99
- package/runtime/sdk/gl/Lightmap.ts +0 -179
- package/runtime/sdk/gl/Material.ts +0 -245
- package/runtime/sdk/gl/Mesh.ts +0 -83
- package/runtime/sdk/gl/Model.ts +0 -64
- package/runtime/sdk/gl/Node.ts +0 -350
- package/runtime/sdk/gl/Noise.ts +0 -30
- package/runtime/sdk/gl/Particles.ts +0 -676
- package/runtime/sdk/gl/Physics.ts +0 -222
- package/runtime/sdk/gl/Plane.ts +0 -53
- package/runtime/sdk/gl/Ray.ts +0 -16
- package/runtime/sdk/gl/Scene.ts +0 -479
- package/runtime/sdk/gl/Shape.ts +0 -377
- package/runtime/sdk/gl/Texture.ts +0 -46
- package/runtime/sdk/gl/Trigger.ts +0 -45
- package/runtime/sdk/gl/Vehicle.ts +0 -473
- package/runtime/sdk/gl/Wheel.ts +0 -240
- package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
- package/runtime/sdk/gl/animation/Animator.ts +0 -87
- package/runtime/sdk/gl/animation/Layer.ts +0 -29
- package/runtime/sdk/gl/animation/Loop.ts +0 -25
- package/runtime/sdk/gl/animation/Playback.ts +0 -43
- package/runtime/sdk/gl/animation/core.ts +0 -294
- package/runtime/sdk/gl/controls.ts +0 -95
- package/runtime/sdk/gl/physicsEvents.ts +0 -20
- package/runtime/sdk/gl/scenarios.ts +0 -291
- package/runtime/sdk/gl/state.ts +0 -6
- package/runtime/sdk/gl/touch.ts +0 -68
- package/runtime/sdk/inject.ts +0 -186
- package/runtime/sdk/math/Mathf.ts +0 -118
- package/runtime/sdk/math/mat4.ts +0 -278
- package/runtime/sdk/math/quat.ts +0 -232
- package/runtime/sdk/math/vec.ts +0 -255
- package/runtime/sdk/plugins/camera.ts +0 -81
- package/runtime/sdk/plugins/geolocation.ts +0 -123
- package/runtime/sdk/plugins/oauth.ts +0 -61
- package/runtime/sdk/plugins/permission.ts +0 -7
- package/runtime/sdk/plugins/push.ts +0 -132
- package/runtime/sdk/plugins/qr.ts +0 -73
- package/runtime/sdk/plugins/service.ts +0 -47
- package/runtime/sdk/runtime/app.ts +0 -101
- package/runtime/sdk/runtime/appEvents.ts +0 -54
- package/runtime/sdk/runtime/channel.ts +0 -61
- package/runtime/sdk/runtime/clipboard.ts +0 -20
- package/runtime/sdk/runtime/datetime.ts +0 -329
- package/runtime/sdk/runtime/device.ts +0 -293
- package/runtime/sdk/runtime/fetch.ts +0 -77
- package/runtime/sdk/runtime/files.ts +0 -23
- package/runtime/sdk/runtime/input.ts +0 -175
- package/runtime/sdk/runtime/media.ts +0 -111
- package/runtime/sdk/runtime/misc.ts +0 -16
- package/runtime/sdk/runtime/net.ts +0 -36
- package/runtime/sdk/runtime/rpc.ts +0 -218
- package/runtime/sdk/runtime/service.ts +0 -83
- package/runtime/sdk/runtime/share.ts +0 -9
- package/runtime/sdk/runtime/storage.ts +0 -13
- package/runtime/sdk/runtime/touch.ts +0 -76
- package/runtime/sdk/scene/defineScene.ts +0 -1227
- package/runtime/sdk/scene/editorPlugins.ts +0 -92
- package/runtime/sdk/scene/gizmos.ts +0 -148
- package/runtime/sdk/scene/grammar.ts +0 -120
- package/runtime/sdk/scene/material.ts +0 -188
- package/runtime/sdk/server/auth/appConfig.ts +0 -12
- package/runtime/sdk/server/auth/global.ts +0 -80
- package/runtime/sdk/server/auth/host.ts +0 -318
- package/runtime/sdk/server/auth/models.ts +0 -83
- package/runtime/sdk/server/auth/types.ts +0 -50
- package/runtime/sdk/server/channel.ts +0 -56
- package/runtime/sdk/server/context.ts +0 -36
- package/runtime/sdk/server/db/defineDb.ts +0 -237
- package/runtime/sdk/server/db/fields.ts +0 -132
- package/runtime/sdk/server/db/httpTransport.ts +0 -93
- package/runtime/sdk/server/db/index.ts +0 -7
- package/runtime/sdk/server/db/marci/query.ts +0 -412
- package/runtime/sdk/server/db/types.ts +0 -202
- package/runtime/sdk/server/errors.ts +0 -12
- package/runtime/sdk/server/host.ts +0 -74
- package/runtime/sdk/server/inject.ts +0 -13
- package/runtime/sdk/server/runtime.ts +0 -133
- package/runtime/sdk/server/validate.ts +0 -87
- package/runtime/sdk/ui/NativeView.ts +0 -142
- package/runtime/sdk/ui/UI.ts +0 -39
- package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
- package/runtime/sdk/ui/UIButton.ts +0 -101
- package/runtime/sdk/ui/UIContainer.ts +0 -60
- package/runtime/sdk/ui/UIImage.ts +0 -83
- package/runtime/sdk/ui/UIInput.ts +0 -185
- package/runtime/sdk/ui/UIModal.ts +0 -139
- package/runtime/sdk/ui/UINode.ts +0 -826
- package/runtime/sdk/ui/UIPager.ts +0 -362
- package/runtime/sdk/ui/UIPopover.ts +0 -100
- package/runtime/sdk/ui/UIScreen.ts +0 -123
- package/runtime/sdk/ui/UIScrollable.ts +0 -87
- package/runtime/sdk/ui/UISpacer.ts +0 -14
- package/runtime/sdk/ui/UITabs.ts +0 -236
- package/runtime/sdk/ui/UIText.ts +0 -51
- package/runtime/sdk/ui/UIVideo.ts +0 -88
- package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
- package/runtime/sdk/ui/UIWidget.ts +0 -127
- package/runtime/sdk/ui/fonts.ts +0 -13
- package/runtime/sdk/ui/presentable.ts +0 -117
- package/runtime/sdk/ui/router.ts +0 -132
- package/runtime/sdk/ui/theme.ts +0 -84
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync } from "node:fs"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import { basename, delimiter, dirname, join, resolve, sep } from "node:path"
|
|
5
|
+
import { c, CliError, flagBool, flagStr, log, success, warn, type Args } from "../util"
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* `lecodes install` — install this standalone build so `lecodes` is on your PATH.
|
|
9
|
+
*
|
|
10
|
+
* The packaged CLI is a small BUNDLE, not a single file: the `lecodes` binary + the `chisel` bundler +
|
|
11
|
+
* `runtime/` (the vendored SDK source + types). The binary resolves those companions next to its REAL
|
|
12
|
+
* path (process.execPath, which follows symlinks — verified), so "install" just means: copy the bundle
|
|
13
|
+
* into a stable library folder and expose the binary on PATH.
|
|
14
|
+
* - Linux/macOS: copy to <libdir>/, symlink <bindir>/lecodes -> <libdir>/lecodes.
|
|
15
|
+
* - Windows: copy to <dir>\ and put that dir on PATH (no symlink, no admin needed).
|
|
16
|
+
*
|
|
17
|
+
* PATH is only edited on request (`--modify-path`); otherwise we place the files and print exactly how
|
|
18
|
+
* to finish (a one-liner for this shell, a line for the rc file, or `--system` which lands in
|
|
19
|
+
* /usr/local/bin and needs no PATH edit). Only meaningful for the release archive — from npm/monorepo
|
|
20
|
+
* source process.execPath has no bundle beside it, so it refuses.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const isWin = process.platform === "win32"
|
|
24
|
+
const HOME = homedir()
|
|
25
|
+
|
|
26
|
+
/** Display a path with the home dir collapsed to `~` (for copy-pasteable hints). */
|
|
27
|
+
const tildify = (p: string): string => (p === HOME || p.startsWith(HOME + sep)) ? "~" + p.slice(HOME.length) : p
|
|
28
|
+
|
|
29
|
+
const safeRealpath = (p: string): string => { try { return realpathSync(p) } catch { return resolve(p) } }
|
|
30
|
+
|
|
31
|
+
/** True if `dir` is already an entry in PATH (compared by resolved path). */
|
|
32
|
+
const onPath = (dir: string): boolean => {
|
|
33
|
+
const want = safeRealpath(dir)
|
|
34
|
+
return (process.env.PATH ?? "").split(delimiter).some(p => {
|
|
35
|
+
if (!p) return false
|
|
36
|
+
try { return safeRealpath(p) === want } catch { return false }
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const install = async (args: Args): Promise<void> => {
|
|
41
|
+
const bundle = dirname(process.execPath)
|
|
42
|
+
const exeName = basename(process.execPath) // "lecodes" | "lecodes.exe"
|
|
43
|
+
const chiselName = isWin ? "chisel.exe" : "chisel"
|
|
44
|
+
|
|
45
|
+
// Guard: this must be the packaged standalone bundle (chisel + runtime/ sit next to the binary).
|
|
46
|
+
// From npm / the monorepo, process.execPath is the bun/node binary and has no companions.
|
|
47
|
+
if (!existsSync(join(bundle, "runtime")) || !existsSync(join(bundle, chiselName))) {
|
|
48
|
+
throw new CliError(
|
|
49
|
+
"`lecodes install` only works from the packaged release build (the archive containing lecodes + " +
|
|
50
|
+
"chisel + runtime/). You appear to be running from source or an npm install — install globally " +
|
|
51
|
+
"with your package manager there instead.",
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const companions = [exeName, chiselName, "runtime", "README.txt"].filter(f => existsSync(join(bundle, f)))
|
|
56
|
+
if (isWin) return installWindows(args, bundle, companions)
|
|
57
|
+
return installUnix(args, bundle, companions)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Copy the bundle's companion files into `target` (skipping the copy when target IS the bundle). */
|
|
61
|
+
const copyBundle = (bundle: string, target: string, companions: string[]): void => {
|
|
62
|
+
mkdirSync(target, { recursive: true })
|
|
63
|
+
if (safeRealpath(target) === safeRealpath(bundle)) return // installing in place — nothing to copy
|
|
64
|
+
for (const f of companions) {
|
|
65
|
+
const dest = join(target, f)
|
|
66
|
+
rmSync(dest, { recursive: true, force: true })
|
|
67
|
+
cpSync(join(bundle, f), dest, { recursive: true })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const permError = (e: unknown, dir: string, system: boolean): CliError => {
|
|
72
|
+
const code = (e as NodeJS.ErrnoException | null)?.code
|
|
73
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
74
|
+
return new CliError(
|
|
75
|
+
`Permission denied writing to ${dir}.` +
|
|
76
|
+
(system ? " Re-run with sudo: sudo lecodes install --system" : " Try without --system, or pick a writable --dir."),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
return new CliError(`Install failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const installUnix = (args: Args, bundle: string, companions: string[]): void => {
|
|
83
|
+
const system = flagBool(args, "system")
|
|
84
|
+
const libDir = resolve(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join(HOME, ".local", "lib", "lecodes-cli")))
|
|
85
|
+
const binDir = resolve(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join(HOME, ".local", "bin")))
|
|
86
|
+
const link = join(binDir, "lecodes")
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
copyBundle(bundle, libDir, companions)
|
|
90
|
+
chmodSync(join(libDir, "lecodes"), 0o755)
|
|
91
|
+
chmodSync(join(libDir, "chisel"), 0o755)
|
|
92
|
+
} catch (e) { throw permError(e, libDir, system) }
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
mkdirSync(binDir, { recursive: true })
|
|
96
|
+
rmSync(link, { force: true }) // replace a previous symlink/file
|
|
97
|
+
symlinkSync(join(libDir, "lecodes"), link)
|
|
98
|
+
} catch (e) { throw permError(e, binDir, system) }
|
|
99
|
+
|
|
100
|
+
success(`Installed to ${c.bold(libDir)}`)
|
|
101
|
+
log(` ${c.dim(link + " → " + join(libDir, "lecodes"))}`)
|
|
102
|
+
finishPathUnix(args, binDir)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The shell startup file to persist PATH into, picked from $SHELL (bash/zsh handled; else ~/.profile,
|
|
106
|
+
* which login shells read). Returns the absolute path and a ~-collapsed display name. */
|
|
107
|
+
const shellRc = (): { file: string, name: string } => {
|
|
108
|
+
const shell = basename(process.env.SHELL ?? "")
|
|
109
|
+
if (shell === "zsh") return { file: join(HOME, ".zshrc"), name: "~/.zshrc" }
|
|
110
|
+
if (shell === "bash") return { file: join(HOME, ".bashrc"), name: "~/.bashrc" }
|
|
111
|
+
return { file: join(HOME, ".profile"), name: "~/.profile" }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const PATH_MARKER = "# Added by `lecodes install`"
|
|
115
|
+
|
|
116
|
+
/** Append an `export PATH=…` line for `binDir` to the shell rc (idempotently). Returns the rc display
|
|
117
|
+
* name on success, or null if the file couldn't be written. */
|
|
118
|
+
const appendPathToShellRc = (binDir: string): string | null => {
|
|
119
|
+
const { file, name } = shellRc()
|
|
120
|
+
const line = `export PATH="${binDir}:$PATH"`
|
|
121
|
+
try {
|
|
122
|
+
const existing = existsSync(file) ? readFileSync(file, "utf8") : ""
|
|
123
|
+
if (existing.includes(line)) return name // already present — no duplicate
|
|
124
|
+
appendFileSync(file, `${existing && !existing.endsWith("\n") ? "\n" : ""}\n${PATH_MARKER}\n${line}\n`)
|
|
125
|
+
return name
|
|
126
|
+
} catch { return null }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Put `binDir` on PATH: verify (already there), or `--modify-path` (edit the rc), or print how. */
|
|
130
|
+
const finishPathUnix = (args: Args, binDir: string): void => {
|
|
131
|
+
if (onPath(binDir)) {
|
|
132
|
+
log(` Run ${c.bold("lecodes --version")} in a new shell to verify.`)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (flagBool(args, "modify-path")) {
|
|
136
|
+
const rc = appendPathToShellRc(binDir)
|
|
137
|
+
if (rc) {
|
|
138
|
+
success(`Added ${c.bold(binDir)} to PATH in ${rc}.`)
|
|
139
|
+
log(` Run ${c.bold(`source ${rc}`)} (or open a new terminal), then ${c.bold("lecodes --version")}.`)
|
|
140
|
+
} else {
|
|
141
|
+
warn("Couldn't edit your shell startup file. Add this line to it manually:")
|
|
142
|
+
log(` ${c.bold(`export PATH="${binDir}:$PATH"`)}`)
|
|
143
|
+
}
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
const { name } = shellRc()
|
|
147
|
+
warn(`${binDir} is not on your PATH yet. To finish, either:`)
|
|
148
|
+
log(` • this shell now: ${c.bold(`export PATH="${binDir}:$PATH"`)}`)
|
|
149
|
+
log(` • all new shells: ${c.bold(`echo 'export PATH="${binDir}:$PATH"' >> ${name}`)} ${c.dim("(then reopen it)")}`)
|
|
150
|
+
log(` • let install do it: re-run with ${c.bold("--modify-path")}`)
|
|
151
|
+
log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const installWindows = (args: Args, bundle: string, companions: string[]): void => {
|
|
155
|
+
const target = resolve(flagStr(args, "dir") ?? join(process.env.LOCALAPPDATA ?? join(HOME, "AppData", "Local"), "Programs", "lecodes-cli"))
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
copyBundle(bundle, target, companions)
|
|
159
|
+
} catch (e) {
|
|
160
|
+
throw new CliError(`Install failed writing to ${target}: ${e instanceof Error ? e.message : String(e)}`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
success(`Installed to ${c.bold(target)}`)
|
|
164
|
+
// No symlink on Windows (they need admin) and no shim needed — the exe resolves chisel/runtime next to
|
|
165
|
+
// itself, so putting the install folder itself on PATH is enough.
|
|
166
|
+
if (onPath(target)) {
|
|
167
|
+
log(` Already on PATH — run ${c.bold("lecodes --version")} in a new terminal.`)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (flagBool(args, "modify-path")) {
|
|
171
|
+
if (addToUserPathWindows(target)) {
|
|
172
|
+
success(`Added ${c.bold(target)} to your user PATH.`)
|
|
173
|
+
log(` ${c.yellow("Open a NEW terminal")} for it to take effect, then run ${c.bold("lecodes --version")}.`)
|
|
174
|
+
} else {
|
|
175
|
+
warn("Couldn't edit PATH automatically. Add this folder to your PATH manually:")
|
|
176
|
+
log(` ${c.bold(target)}`)
|
|
177
|
+
}
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
warn(`${target} is not on your PATH yet. To finish, either:`)
|
|
181
|
+
log(` • let install do it: re-run with ${c.bold("--modify-path")} (adds it to your user PATH)`)
|
|
182
|
+
log(` • or add the folder manually via System → Environment Variables.`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Append `dir` to the current user's Path via the .NET env API (PowerShell). Unlike `setx` this never
|
|
186
|
+
* truncates a long Path. The dir is passed through an env var to avoid any quoting/injection. */
|
|
187
|
+
const addToUserPathWindows = (dir: string): boolean => {
|
|
188
|
+
const script =
|
|
189
|
+
"$d = $env:LECODES_INSTALL_DIR;" +
|
|
190
|
+
"$p = [Environment]::GetEnvironmentVariable('Path','User'); if (-not $p) { $p = '' };" +
|
|
191
|
+
"$parts = @($p.Split(';') | Where-Object { $_ -ne '' });" +
|
|
192
|
+
"if ($parts -notcontains $d) { [Environment]::SetEnvironmentVariable('Path', (@($parts + $d) -join ';'), 'User') }"
|
|
193
|
+
try {
|
|
194
|
+
execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script],
|
|
195
|
+
{ stdio: "ignore", env: { ...process.env, LECODES_INSTALL_DIR: dir } })
|
|
196
|
+
return true
|
|
197
|
+
} catch { return false }
|
|
198
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join, relative, resolve } from "node:path"
|
|
5
|
+
import { findProjectRoot } from "../manifest"
|
|
6
|
+
import { buildBundle } from "./compile"
|
|
7
|
+
import { readAppConfigAt, resolveDesktopRenderer } from "./appShared"
|
|
8
|
+
import { runDesktopRender } from "../desktopRenderer"
|
|
9
|
+
import { CliError, c, flagStr, flagBool, logErr, note, warnErr, type Args } from "../util"
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* `lecodes lightmap bake` — bake static shadows, ambient occlusion and the level's point lights for
|
|
13
|
+
* the local project (docs/lightmap-plan.md). The app is compiled with a synthetic entry that puts it
|
|
14
|
+
* in BAKE mode (`globalThis.__lecodesLightmap = { outDir, … }`, then `import "./main"`) and run in the
|
|
15
|
+
* native desktop host; when the app calls `Lightmap.load(...)` — the point where its scene is
|
|
16
|
+
* complete — the SDK sees the flag and calls `_creator.lightmapBake` instead of loading: creator-gl
|
|
17
|
+
* traces every `Lightmap.add`-registered static against the scene's sun and point lights and writes
|
|
18
|
+
* its rasters (PNGs, INTERMEDIATES) + lightmap.bake into --out. The host's stdout carries
|
|
19
|
+
* "[lightmap] …" progress lines; "[lightmap] done" is the success marker. The atlases then ship as
|
|
20
|
+
* **KTX2**: the CLI runs KTX-Software's toktx (UASTC + zstd + mips, sRGB transfer function — the bake
|
|
21
|
+
* encodes sRGB on purpose, precision in the darks) and deletes the PNGs. toktx is REQUIRED — the
|
|
22
|
+
* command refuses to start without it, so a bake never leaves a half-finished output behind.
|
|
23
|
+
*
|
|
24
|
+
* Output, under --out with the --name stem (default `lightmap`, so a project with several baked
|
|
25
|
+
* levels gives each its own stem — `app build desktop` rejects two resources of one basename):
|
|
26
|
+
* <name>.bake rects, pages, the baked lights (what the scene's `data` names)
|
|
27
|
+
* <name>.ktx2, <name>_1.ktx2… the atlas pages — R = sun visibility, G = ambient occlusion
|
|
28
|
+
* <name>-light[_n].ktx2 the point lights' baked irradiance, only when the level holds any
|
|
29
|
+
* <name>.volume the light volume for movers (unless --no-volume)
|
|
30
|
+
*
|
|
31
|
+
* Only the desktop host has the bake module (a dev tool — never in the wasm / mobile engines), so
|
|
32
|
+
* this needs a host with it: a local build via LECODES_DESKTOP_EXE until the release ships it.
|
|
33
|
+
* The props need lightmap UVs first: `lecodes assets doctor assets/props/*.glb --lightmap-uv`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const num = (args: Args, key: string, fallback: number, min: number, max: number): number => {
|
|
37
|
+
const raw = flagStr(args, key)
|
|
38
|
+
if (raw === undefined) return fallback
|
|
39
|
+
const n = Number(raw)
|
|
40
|
+
if (!Number.isFinite(n) || n < min || n > max) throw new CliError(`--${key} wants a number in ${min}..${max}, got "${raw}".`)
|
|
41
|
+
return n
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---- toktx (KTX-Software) — same lookup as lecodes-assets' doctor --------------------------------
|
|
45
|
+
|
|
46
|
+
const findToktx = (): string => {
|
|
47
|
+
const env = process.env.KTX_TOKTX
|
|
48
|
+
if (env && existsSync(env)) return env
|
|
49
|
+
for (const base of [ process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "Programs"), process.env.ProgramFiles ]) {
|
|
50
|
+
if (!base) continue
|
|
51
|
+
const guess = join(base, "KTX-Software", "bin", "toktx.exe")
|
|
52
|
+
if (existsSync(guess)) return guess
|
|
53
|
+
}
|
|
54
|
+
return "toktx" // on PATH
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const checkToktx = (toktx: string): string | null => {
|
|
58
|
+
try {
|
|
59
|
+
const p = spawnSync(toktx, [ "--version" ], { encoding: "utf8" })
|
|
60
|
+
if (p.status === 0) return (p.stdout ?? "").toString().trim() || "toktx"
|
|
61
|
+
} catch { /* not found */ }
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const requireToktx = (): string => {
|
|
66
|
+
const toktx = findToktx()
|
|
67
|
+
if (!checkToktx(toktx)) {
|
|
68
|
+
throw new CliError("lecodes lightmap bake needs KTX-Software's toktx — the atlas ships as KTX2 (GPU-compressed, mipmapped).\n Install it from https://github.com/KhronosGroup/KTX-Software/releases (make sure toktx is on PATH) or set KTX_TOKTX=<path to toktx>.")
|
|
69
|
+
}
|
|
70
|
+
return toktx
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** PNG raster → .ktx2: UASTC (the high-quality Basis codec — ETC1S blocks the shadow edges),
|
|
74
|
+
* zstd supercompression, a full mip chain, sRGB transfer function (what the bake encodes and what
|
|
75
|
+
* the ordinary texture path expects; the KTX2 reader refuses a mismatch instead of guessing). */
|
|
76
|
+
// Async so the pages encode CONCURRENTLY: toktx's zstd + mip stages are serial per file, and the four
|
|
77
|
+
// pages of a two-page bake with a light atlas took 23 s one after another against 10 s side by side.
|
|
78
|
+
const encodeKtx2 = (toktx: string, png: string, ktx2: string): Promise<void> => new Promise((resolve, reject) => {
|
|
79
|
+
const args = [ "--t2", "--encode", "uastc", "--uastc_quality", "2", "--zcmp", "18", "--genmipmap", "--assign_oetf", "srgb", ktx2, png ]
|
|
80
|
+
const p = spawn(toktx, args, { stdio: [ "ignore", "pipe", "pipe" ] })
|
|
81
|
+
let out = ""
|
|
82
|
+
p.stdout.on("data", (d) => { out += String(d) })
|
|
83
|
+
p.stderr.on("data", (d) => { out += String(d) })
|
|
84
|
+
p.on("error", (e) => reject(new CliError(`toktx failed (${e.message}) while encoding ${ktx2}`)))
|
|
85
|
+
p.on("close", (status) => {
|
|
86
|
+
if (status === 0) resolve()
|
|
87
|
+
else reject(new CliError(`toktx failed (${status ?? "?"}) while encoding ${ktx2}:\n${out.trim()}`))
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/** The bake's file names for `stem`: page 0 is `<stem>.ktx2`, page n `<stem>_n.ktx2`; the light atlas adds `-light`. */
|
|
92
|
+
const pageFile = (stem: string, page: number, light: boolean, ext = ".ktx2"): string =>
|
|
93
|
+
`${stem}${light ? "-light" : ""}${page > 0 ? `_${page}` : ""}${ext}`
|
|
94
|
+
|
|
95
|
+
const bake = async (args: Args): Promise<void> => {
|
|
96
|
+
let root: string
|
|
97
|
+
try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() }
|
|
98
|
+
const toktx = requireToktx()
|
|
99
|
+
const outDir = resolve(root, flagStr(args, "out") ?? "assets/lightmap")
|
|
100
|
+
const stem = flagStr(args, "name") ?? "lightmap"
|
|
101
|
+
if (!/^[A-Za-z0-9_-]+$/.test(stem)) throw new CliError(`--name wants a plain file stem (letters, digits, - and _), got "${stem}".`)
|
|
102
|
+
const entryFlag = flagStr(args, "entry")
|
|
103
|
+
const entryFile = entryFlag ?? "main.ts"
|
|
104
|
+
if (!existsSync(resolve(root, entryFile))) {
|
|
105
|
+
throw new CliError(`No ${entryFile} in ${root} — pass --entry <file> (the file that builds the scene and calls Lightmap.load).`)
|
|
106
|
+
}
|
|
107
|
+
const cfg = {
|
|
108
|
+
outDir: outDir.replace(/\\/g, "/"),
|
|
109
|
+
size: num(args, "size", 4096, 256, 16384),
|
|
110
|
+
texel: num(args, "texel", 0.02, 0.001, 2),
|
|
111
|
+
// atlas PAGES: the bake fills up to this many atlases of --size at the requested texel before it
|
|
112
|
+
// coarsens; every page is one more texture the scene lists under env.lightmap.texture
|
|
113
|
+
pages: num(args, "pages", 4, 1, 16),
|
|
114
|
+
sunRays: num(args, "sun-rays", 32, 1, 1024),
|
|
115
|
+
aoRays: num(args, "ao-rays", 64, 1, 4096),
|
|
116
|
+
aoDistance: num(args, "ao-distance", 2, 0.01, 1000),
|
|
117
|
+
bias: num(args, "bias", 0.02, 0.0001, 1),
|
|
118
|
+
sunAngle: num(args, "sun-angle", 0.5, 0.01, 30),
|
|
119
|
+
// point lights: shadow rays per light per texel, toward a disc of this radius at the light
|
|
120
|
+
lightRays: num(args, "light-rays", 16, 1, 256),
|
|
121
|
+
lightRadius: num(args, "light-radius", 0.1, 0.001, 5),
|
|
122
|
+
// One bounce of indirect from the lamps, gathered on the AO rays (the albedo it assumes for
|
|
123
|
+
// every surface it hits). Off by default: it is the difference between "a room lit by its
|
|
124
|
+
// lamps" and "lit fixtures in a black room", but it costs a full Intersect per AO ray.
|
|
125
|
+
bounce: num(args, "bounce", 0, 0, 1),
|
|
126
|
+
bounceDistance: num(args, "bounce-distance", 8, 0.5, 100),
|
|
127
|
+
bounceRays: num(args, "bounce-rays", 1, 1, 32),
|
|
128
|
+
coplanarSkip: num(args, "coplanar-skip", 0.05, 0, 1),
|
|
129
|
+
// geometry-aware denoise of the traced texels (radius in px; 0 = off)
|
|
130
|
+
denoise: num(args, "denoise", 2, 0, 4),
|
|
131
|
+
// the light volume for dynamic objects (docs/lightmap-plan.md §9); 0 = skip it
|
|
132
|
+
volumeCell: flagBool(args, "no-volume") ? 0 : num(args, "cell", 0.5, 0.05, 20),
|
|
133
|
+
}
|
|
134
|
+
const frames = num(args, "frames", 900, 30, 100000)
|
|
135
|
+
const win = readAppConfigAt(root)?.desktop?.window
|
|
136
|
+
const width = Number(flagStr(args, "width")) || win?.width || 1280
|
|
137
|
+
const height = Number(flagStr(args, "height")) || win?.height || 720
|
|
138
|
+
const logs = flagBool(args, "logs")
|
|
139
|
+
|
|
140
|
+
// The synthetic entry: bake mode flag first, then the real entry. It lives at the project root so
|
|
141
|
+
// the real entry's own relative imports resolve unchanged.
|
|
142
|
+
const importPath = "./" + entryFile.replace(/\\/g, "/").replace(/\.tsx?$/, "")
|
|
143
|
+
const entryPath = "/.lightmap-entry.ts"
|
|
144
|
+
const entryText = `globalThis.__lecodesLightmap = ${JSON.stringify(cfg)}\nimport ${JSON.stringify(importPath)}\n`
|
|
145
|
+
|
|
146
|
+
// The bake files must exist BEFORE the compile: the scene references them through asset(), and
|
|
147
|
+
// a missing asset is a broken bundle (chisel emits the constant after its use — a TDZ crash at
|
|
148
|
+
// boot, seen 2026-08-26). The first bake starts from placeholders the real files overwrite;
|
|
149
|
+
// Lightmap.load on a placeholder applies nothing and says "rebake".
|
|
150
|
+
mkdirSync(outDir, { recursive: true })
|
|
151
|
+
ensurePlaceholders(outDir, stem)
|
|
152
|
+
|
|
153
|
+
const desktopRenderer = resolveDesktopRenderer(root, args)
|
|
154
|
+
const { js, resources } = await buildBundle(args, undefined, {
|
|
155
|
+
localAssets: true, desktopRenderer,
|
|
156
|
+
extraEntries: [{ path: entryPath, type: "text", text: entryText }],
|
|
157
|
+
entryOverride: entryPath,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// The host writes the bake itself; the PNG the render driver insists on goes to a scratch dir.
|
|
161
|
+
const scratch = mkdtempSync(join(tmpdir(), "lecodes-lightmap-"))
|
|
162
|
+
const outPng = join(scratch, "frame.png")
|
|
163
|
+
note(`Baking up to ${cfg.pages} × ${cfg.size}² at ${(cfg.texel * 100).toFixed(1)} cm texels (${cfg.sunRays} sun + ${cfg.aoRays} AO + ${cfg.lightRays} light rays)${cfg.volumeCell ? ` + a ${cfg.volumeCell} m light volume` : ""} → ${relative(root, outDir) || "."}/${stem}.*…`)
|
|
164
|
+
let output = ""
|
|
165
|
+
try {
|
|
166
|
+
// A bake is not interactive and a large level traces for a long time: no timeout unless asked
|
|
167
|
+
// (the host prints its progress once a second — a silent host is the thing to watch, not the clock).
|
|
168
|
+
const timeoutMs = Number(flagStr(args, "timeout")) || 24 * 3600 * 1000
|
|
169
|
+
const res = await runDesktopRender(js, resources, {
|
|
170
|
+
renderer: desktopRenderer, width, height, frames, outPng, logs, timeoutMs,
|
|
171
|
+
// the bake is synchronous inside Lightmap.load — once the marker is out there is nothing to wait for
|
|
172
|
+
stopOnLine: (chunk) => chunk.includes("[lightmap] done") || chunk.includes("[lightmap] error"),
|
|
173
|
+
// no script: the frame cap ends the run once Lightmap.load has fired (the bake is synchronous)
|
|
174
|
+
})
|
|
175
|
+
output = res.output
|
|
176
|
+
} catch (e) {
|
|
177
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
178
|
+
// A run that baked and then hit the cap without a PNG is still a success — check the markers.
|
|
179
|
+
if (!/\[lightmap\] done/.test(msg)) throw e
|
|
180
|
+
output = msg
|
|
181
|
+
} finally {
|
|
182
|
+
try { rmSync(scratch, { recursive: true, force: true }) } catch { /* a killed host may hold the dir a moment longer */ }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const lines = output.split(/\r?\n/).filter((l) => l.startsWith("[lightmap]"))
|
|
186
|
+
for (const l of lines) {
|
|
187
|
+
const text = l.slice("[lightmap]".length).trim()
|
|
188
|
+
if (text === "done") continue
|
|
189
|
+
logErr(text.startsWith("error") ? c.red(`✗ ${text}`) : text.startsWith("warning") ? c.yellow(` ${text}`) : c.dim(` ${text}`))
|
|
190
|
+
}
|
|
191
|
+
if (/\[lightmap\] done/.test(output)) {
|
|
192
|
+
// the host writes `lightmap.*`; the stem is the CLI's, applied while the PNGs turn into KTX2
|
|
193
|
+
const bakePath = join(outDir, "lightmap.bake")
|
|
194
|
+
if (!existsSync(bakePath)) throw new CliError(`The host reported a bake but ${bakePath} is missing.`)
|
|
195
|
+
const data = JSON.parse(readFileSync(bakePath, "utf8")) as { pages?: number, textures?: string[], lightTextures?: string[], volume?: { file: string }, texture?: string }
|
|
196
|
+
const pages = Math.max(1, data.pages ?? 1)
|
|
197
|
+
const hasLight = !!data.lightTextures?.length
|
|
198
|
+
const t0 = Date.now()
|
|
199
|
+
let pngKb = 0, ktxKb = 0
|
|
200
|
+
const encodePage = async (page: number, light: boolean): Promise<void> => {
|
|
201
|
+
const png = join(outDir, pageFile("lightmap", page, light, ".png"))
|
|
202
|
+
const ktx2 = join(outDir, pageFile(stem, page, light))
|
|
203
|
+
if (!existsSync(png)) throw new CliError(`The host reported a bake but ${png} is missing.`)
|
|
204
|
+
await encodeKtx2(toktx, png, ktx2)
|
|
205
|
+
pngKb += statSync(png).size / 1024; ktxKb += statSync(ktx2).size / 1024
|
|
206
|
+
unlinkSync(png)
|
|
207
|
+
}
|
|
208
|
+
const encodes: Promise<void>[] = []
|
|
209
|
+
for (let p = 0; p < pages; p++) {
|
|
210
|
+
encodes.push(encodePage(p, false))
|
|
211
|
+
if (hasLight) encodes.push(encodePage(p, true))
|
|
212
|
+
}
|
|
213
|
+
await Promise.all(encodes)
|
|
214
|
+
// a page the previous bake had and this one does not: keep the file a scene may still list, as a placeholder
|
|
215
|
+
for (let p = pages; p < 16; p++) {
|
|
216
|
+
for (const light of [ false, true ]) {
|
|
217
|
+
const stale = join(outDir, pageFile(stem, p, light))
|
|
218
|
+
if (existsSync(stale) && statSync(stale).size !== PLACEHOLDER_KTX2.length) writeFileSync(stale, PLACEHOLDER_KTX2)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!hasLight) {
|
|
222
|
+
// no point lights in the level: a light atlas the scene may still reference goes back to a placeholder
|
|
223
|
+
const light = join(outDir, pageFile(stem, 0, true))
|
|
224
|
+
if (existsSync(light) && statSync(light).size !== PLACEHOLDER_KTX2.length) writeFileSync(light, PLACEHOLDER_KTX2)
|
|
225
|
+
}
|
|
226
|
+
// the .bake names the encoded files under the stem
|
|
227
|
+
data.texture = pageFile(stem, 0, false)
|
|
228
|
+
data.textures = Array.from({ length: pages }, (_, p) => pageFile(stem, p, false))
|
|
229
|
+
if (hasLight) data.lightTextures = Array.from({ length: pages }, (_, p) => pageFile(stem, p, true))
|
|
230
|
+
if (data.volume) data.volume.file = `${stem}.volume`
|
|
231
|
+
writeFileSync(join(outDir, `${stem}.bake`), JSON.stringify(data, null, 1) + "\n")
|
|
232
|
+
if (stem !== "lightmap") unlinkSync(bakePath)
|
|
233
|
+
const volumeSrc = join(outDir, "lightmap.volume")
|
|
234
|
+
const hasVolume = cfg.volumeCell > 0 && existsSync(volumeSrc)
|
|
235
|
+
if (hasVolume && stem !== "lightmap") { rmSync(join(outDir, `${stem}.volume`), { force: true }); renameSync(volumeSrc, join(outDir, `${stem}.volume`)) }
|
|
236
|
+
logErr(c.dim(` encoded ${pages} page(s)${hasLight ? " + the light atlas" : ""} (UASTC + mips, ${Math.round(ktxKb)} KB from ${Math.round(pngKb)} KB of rasters) in ${((Date.now() - t0) / 1000).toFixed(1)} s`))
|
|
237
|
+
logErr(c.green(`✓ baked → ${relative(root, outDir) || "."}/${stem}.bake + ${data.textures.join(", ")}${hasLight ? ` + ${data.lightTextures!.join(", ")}` : ""}${hasVolume ? ` + ${stem}.volume` : ""}`))
|
|
238
|
+
const rel = (f: string): string => relative(root, join(outDir, f)).replace(/\\/g, "/")
|
|
239
|
+
const assets = (files: string[]): string => files.length === 1 ? `asset('<rel>/${rel(files[0])}')` : `[${files.map((f) => `asset('<rel>/${rel(f)}')`).join(", ")}]`
|
|
240
|
+
logErr(c.dim(` scene file: env.lightmap: { data: asset('<rel>/${rel(`${stem}.bake`)}'), texture: ${assets(data.textures)}${hasLight ? `, light: ${assets(data.lightTextures!)}` : ""}${hasVolume ? `, volume: asset('<rel>/${rel(`${stem}.volume`)}')` : ""} } — asset() paths are relative to the scene file`))
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
if (lines.some((l) => /no bake module/.test(l))) {
|
|
244
|
+
throw new CliError("This desktop host has no bake module. Build packages/desktop (CREATOR_GL_LIGHTMAP is on there) and point LECODES_DESKTOP_EXE at build/lecodes-desktop.exe, or update: lecodes desktop update.")
|
|
245
|
+
}
|
|
246
|
+
if (lines.some((l) => /error/.test(l))) throw new CliError("The bake failed — see the lines above.")
|
|
247
|
+
warnErr("The host ran to its frame cap without baking. Make sure the app calls Lightmap.load(...) after the static scene is built, and skips menus when Lightmap.baking is true (a title screen never reaches the scene). More load headroom: --frames <n>. Host output: --logs.")
|
|
248
|
+
throw new CliError("Nothing was baked.")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A 1x1 white UASTC KTX2 (the atlas' "no shadow, no occlusion" value; toktx v4.4.2, sRGB) + an empty
|
|
252
|
+
* bake — enough for the compiler to stage the files and for Lightmap.load to no-op with a "rebake"
|
|
253
|
+
* warning. */
|
|
254
|
+
const PLACEHOLDER_KTX2 = Buffer.from(
|
|
255
|
+
"q0tUWCAyMLsNChoKAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAEAAAAAAAAAaAAAACwAAACUAAAAfAAAAAAAAAAAAAAAAAAAAAAAAAAQAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAsAAAAAAAAAAIAKACmAQIAAwMAABAAAAAAAAAAAAB/AAAAAAAAAAAA/////xEAAABLVFhvcmllbnRhdGlvbgByAAAAACcAAABLVFh3cml0ZXIAdG9rdHggdjQuNC4yIC8gbGlia3R4IHY0LjQuMgAAMwAAAEtUWHdyaXRlclNjUGFyYW1zAC0tZW5jb2RlIHVhc3RjIC0tdWFzdGNfcXVhbGl0eSAwAAD3////H3zvAQAAAAAAAAAA",
|
|
256
|
+
"base64")
|
|
257
|
+
|
|
258
|
+
/** A 1x1x1 white light volume ("LVOL" v1, one lit cell): Lightmap.load treats a single-cell volume as "nothing baked". */
|
|
259
|
+
const placeholderVolume = (): Buffer => {
|
|
260
|
+
const b = Buffer.alloc(4 + 4 + 12 + 12 + 4 + 2)
|
|
261
|
+
b.write("LVOL", 0, "latin1")
|
|
262
|
+
b.writeUInt32LE(1, 4)
|
|
263
|
+
b.writeUInt32LE(1, 8); b.writeUInt32LE(1, 12); b.writeUInt32LE(1, 16)
|
|
264
|
+
b.writeFloatLE(0, 20); b.writeFloatLE(0, 24); b.writeFloatLE(0, 28)
|
|
265
|
+
b.writeFloatLE(1, 32)
|
|
266
|
+
b[36] = 255; b[37] = 255
|
|
267
|
+
return b
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The files a scene may reference before the first bake exists: the .bake, page 0 of both atlases
|
|
271
|
+
* and the volume (later pages come from a bake that needed them). An EMPTY file counts as missing —
|
|
272
|
+
* a zero-byte KTX2 is what hand-made placeholders looked like, and the host cannot build a texture
|
|
273
|
+
* from it. */
|
|
274
|
+
const ensurePlaceholders = (outDir: string, stem: string): void => {
|
|
275
|
+
const missing = (f: string): boolean => !existsSync(f) || statSync(f).size === 0
|
|
276
|
+
const bake = join(outDir, `${stem}.bake`)
|
|
277
|
+
const ktx2 = join(outDir, pageFile(stem, 0, false))
|
|
278
|
+
const light = join(outDir, pageFile(stem, 0, true))
|
|
279
|
+
const volume = join(outDir, `${stem}.volume`)
|
|
280
|
+
if (missing(bake)) writeFileSync(bake, JSON.stringify({ version: 2, placeholder: true, size: 1, texel: 0, pages: 1, instances: [] }) + "\n")
|
|
281
|
+
if (missing(ktx2)) writeFileSync(ktx2, PLACEHOLDER_KTX2)
|
|
282
|
+
if (missing(light)) writeFileSync(light, PLACEHOLDER_KTX2)
|
|
283
|
+
if (missing(volume)) writeFileSync(volume, placeholderVolume())
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export const lightmap = async (args: Args): Promise<void> => {
|
|
287
|
+
const sub = args._[0]
|
|
288
|
+
if (sub === "bake") return bake({ ...args, _: args._.slice(1) })
|
|
289
|
+
throw new CliError("Usage: lecodes lightmap bake [--out assets/lightmap] [--name lightmap] [--size 4096] [--pages 4] [--texel 0.02] [--sun-rays 32] [--ao-rays 64] [--ao-distance 2] [--bias 0.02] [--sun-angle 0.5] [--light-rays 16] [--light-radius 0.1] [--bounce 0..1] [--denoise 2] [--cell 0.5 | --no-volume] [--frames 900] [--timeout <ms>] [--entry main.ts] [--logs] (needs KTX-Software's toktx)")
|
|
290
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { basename, join, resolve } from "node:path"
|
|
3
|
+
import { createProject, getCommits, getProject, type Project, type ProjectAsset } from "../api"
|
|
4
|
+
import { loadConfig, requireApiUrl, requireToken } from "../config"
|
|
5
|
+
import { ensureGitignore, writeDefaultIgnore } from "../ignore"
|
|
6
|
+
import { scanProject } from "../localFiles"
|
|
7
|
+
import { findProjectRoot, LECODES_DIR, writeManifest, type FileKind, type ManifestFile, type Manifest } from "../manifest"
|
|
8
|
+
import { buildPaths } from "../project"
|
|
9
|
+
import { computeServerDiff } from "../serverDiff"
|
|
10
|
+
import { materializeTypes, writeTsconfig } from "../types"
|
|
11
|
+
import { CliError, c, confirm, flagBool, flagStr, info, log, note, sha256, success, warn, type Args } from "../util"
|
|
12
|
+
import { parseUuid } from "./clone"
|
|
13
|
+
import { push } from "./push"
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* `lecodes link [dir]` — connect an existing local folder (typically one made by `lecodes init`) to
|
|
17
|
+
* the platform, so `status` / `push` / `pull` work on it. The mirror image of `clone`.
|
|
18
|
+
*
|
|
19
|
+
* Everything push needs is <root>/.lecodes/manifest.json, so linking is exactly: pick the remote
|
|
20
|
+
* project (create a fresh one, or adopt an existing one via --project), write that manifest, and
|
|
21
|
+
* refresh the types from the server now that there is one. Nothing is uploaded — the first
|
|
22
|
+
* `lecodes push` does that, so the user sees `status` before anything leaves the machine.
|
|
23
|
+
*
|
|
24
|
+
* Adopting a NON-EMPTY project is the one sharp edge: push is a set-tree, so it would delete
|
|
25
|
+
* whatever the server has and the local folder doesn't. The manifest is therefore built from the
|
|
26
|
+
* SERVER's tree (not empty), which makes `status`/`diff` truthful, and the exact server-side
|
|
27
|
+
* consequence is printed and confirmed before the link is written.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const kindOf = (type: ProjectAsset["type"]): FileKind => (type === "file" ? "resource" : type as FileKind)
|
|
31
|
+
|
|
32
|
+
/** Manifest file map describing the project's CURRENT server state, without downloading anything.
|
|
33
|
+
* Text assets carry their content, so their sha is exact and unchanged files show as unchanged;
|
|
34
|
+
* binaries aren't fetched here — an empty sha reads as "changed", so the first push re-uploads
|
|
35
|
+
* them. That costs bandwidth once and keeps linking a metadata-only operation. */
|
|
36
|
+
const manifestFromServer = (project: Project): Record<string, ManifestFile> => {
|
|
37
|
+
const pathById = buildPaths(project.assets)
|
|
38
|
+
const files: Record<string, ManifestFile> = {}
|
|
39
|
+
for (const asset of project.assets) {
|
|
40
|
+
if (asset.type === "folder") continue
|
|
41
|
+
files[pathById.get(asset.id)!] = {
|
|
42
|
+
id: asset.id,
|
|
43
|
+
type: kindOf(asset.type),
|
|
44
|
+
sha: asset.type === "file" ? "" : sha256(asset.text ?? ""),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return files
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Show what a push from this folder would do to the project's server tree, and get a yes. */
|
|
51
|
+
const confirmAdoption = async (root: string, project: Project, files: Record<string, ManifestFile>, args: Args): Promise<boolean> => {
|
|
52
|
+
const count = Object.keys(files).length
|
|
53
|
+
if (count === 0) {
|
|
54
|
+
info(c.dim(`Project ${c.bold(project.name)} is empty on the server — nothing to overwrite.`))
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const scan = scanProject(root, { files } as Manifest)
|
|
59
|
+
const diff = computeServerDiff(project, scan)
|
|
60
|
+
if (diff.length === 0) {
|
|
61
|
+
info(c.dim(`Project ${c.bold(project.name)} (${count} file(s)) already matches this folder.`))
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
warn(`${c.bold(project.name)} already has ${count} file(s). Your next push would change the server:`)
|
|
66
|
+
const label: Record<string, string> = { added: c.green(" create "), removed: c.red(" delete "), modified: c.yellow(" update ") }
|
|
67
|
+
for (const e of diff) log(label[e.status] + e.path + (e.binary ? c.dim(" (binary)") : ""))
|
|
68
|
+
log("")
|
|
69
|
+
note("`lecodes pull` after linking takes the server's version instead.")
|
|
70
|
+
|
|
71
|
+
if (flagBool(args, "y", "yes") || flagBool(args, "force") || !process.stdin.isTTY) return true
|
|
72
|
+
return confirm("Link this folder to that project?", false)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const link = async (args: Args) => {
|
|
76
|
+
const config = loadConfig()
|
|
77
|
+
const apiUrl = requireApiUrl(config)
|
|
78
|
+
const token = requireToken(config)
|
|
79
|
+
|
|
80
|
+
const dirArg = args._[0]
|
|
81
|
+
const root = dirArg ? resolve(dirArg) : process.cwd()
|
|
82
|
+
if (!existsSync(root)) throw new CliError(`No such directory: ${root}`)
|
|
83
|
+
|
|
84
|
+
// Already linked? Re-linking silently would orphan the old project's history.
|
|
85
|
+
if (existsSync(join(root, LECODES_DIR, "manifest.json")) && !flagBool(args, "force")) {
|
|
86
|
+
throw new CliError("This folder is already linked (.lecodes/manifest.json exists) — use `lecodes push`, or `lecodes link --force` to re-link it to another project.")
|
|
87
|
+
}
|
|
88
|
+
// …or sitting inside a linked project, where a nested manifest would shadow the real root.
|
|
89
|
+
let enclosing: string | null = null
|
|
90
|
+
try { enclosing = findProjectRoot(root) } catch { /* not inside a project — the normal case */ }
|
|
91
|
+
if (enclosing && enclosing !== root) {
|
|
92
|
+
throw new CliError(`This folder is inside the linked project at ${enclosing} — push from there instead.`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const projectRef = flagStr(args, "project")
|
|
96
|
+
let uuid: string
|
|
97
|
+
let name: string
|
|
98
|
+
let files: Record<string, ManifestFile>
|
|
99
|
+
|
|
100
|
+
if (projectRef) {
|
|
101
|
+
uuid = parseUuid(projectRef)
|
|
102
|
+
const project = await getProject(apiUrl, token, uuid)
|
|
103
|
+
if (project.role === "viewer") {
|
|
104
|
+
throw new CliError(`You have view-only access to ${project.name} — linking would give you a checkout you can't push.`)
|
|
105
|
+
}
|
|
106
|
+
files = manifestFromServer(project)
|
|
107
|
+
if (!(await confirmAdoption(root, project, files, args))) { info(c.dim("Aborted — nothing was linked.")); return }
|
|
108
|
+
name = project.name
|
|
109
|
+
} else {
|
|
110
|
+
name = flagStr(args, "name") ?? basename(root)
|
|
111
|
+
info(c.dim(`Creating project ${c.bold(name)}…`))
|
|
112
|
+
uuid = (await createProject(apiUrl, token, name, flagStr(args, "team"))).uuid
|
|
113
|
+
files = {} // fresh project: the whole folder is a pending add
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const commits = await getCommits(apiUrl, token, uuid)
|
|
117
|
+
writeManifest(root, { uuid, apiUrl, name, baseHeadId: commits.headId, files })
|
|
118
|
+
log(`${c.green("linked")} ${c.bold(name)} ${c.dim(uuid)}`)
|
|
119
|
+
|
|
120
|
+
// Now that there IS a server, take its type surface — it matches the runtime the project will
|
|
121
|
+
// actually run on, unlike the CLI-bundled copy `lecodes init` wrote.
|
|
122
|
+
if (!flagBool(args, "no-types")) {
|
|
123
|
+
await materializeTypes(root, apiUrl)
|
|
124
|
+
writeTsconfig(root)
|
|
125
|
+
log(`${c.green("written")} ${LECODES_DIR}/types/ + tsconfig.json ${c.dim("(from the server)")}`)
|
|
126
|
+
}
|
|
127
|
+
writeDefaultIgnore(root)
|
|
128
|
+
if (ensureGitignore(root)) log(`${c.green("written")} .gitignore ${c.dim("(.lecodes/ is per-checkout state)")}`)
|
|
129
|
+
|
|
130
|
+
if (flagBool(args, "push")) {
|
|
131
|
+
log("")
|
|
132
|
+
if (root !== process.cwd()) process.chdir(root) // push works from the current project root
|
|
133
|
+
await push(args)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const pending = scanProject(root, { files } as Manifest).files.filter((f) => {
|
|
138
|
+
const known = files[f.path]
|
|
139
|
+
return !known || known.sha !== f.sha
|
|
140
|
+
}).length
|
|
141
|
+
log("")
|
|
142
|
+
success(`Connected to ${c.bold(name)}.`)
|
|
143
|
+
const cd = dirArg ? `cd ${dirArg} && ` : ""
|
|
144
|
+
log(` ${c.bold(cd + "lecodes status")} ${pending} file(s) would go up`)
|
|
145
|
+
log(` ${c.bold(cd + 'lecodes push -m "initial"')} push them as the first checkpoint`)
|
|
146
|
+
log(` ${c.dim("(add --publish to deploy it live in the same step)")}`)
|
|
147
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getAccount } from "../api"
|
|
2
|
+
import { DEFAULT_API, loadConfig, normalizeApiUrl, saveConfig } from "../config"
|
|
3
|
+
import { browserLogin } from "../browserAuth"
|
|
4
|
+
import { flagStr, success, c, type Args } from "../util"
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* `lecodes login` — authorize this machine and store a personal access token in ~/.lecodes.
|
|
8
|
+
* - default: open the browser, sign in / authorize on le.codes, capture the token.
|
|
9
|
+
* - `--token <pat>`: store a token created in the web UI (headless / CI).
|
|
10
|
+
* - `--api <url>`: API origin (defaults to https://le.codes).
|
|
11
|
+
* - `--web <url>`: app origin for the browser flow (defaults to the API origin).
|
|
12
|
+
*/
|
|
13
|
+
export const login = async (args: Args) => {
|
|
14
|
+
const config = loadConfig()
|
|
15
|
+
const apiUrl = normalizeApiUrl(flagStr(args, "api") ?? config.apiUrl ?? DEFAULT_API)
|
|
16
|
+
const webBase = normalizeApiUrl(flagStr(args, "web") ?? apiUrl)
|
|
17
|
+
|
|
18
|
+
const token = flagStr(args, "token") ?? await browserLogin(webBase)
|
|
19
|
+
|
|
20
|
+
// Verify the token works against the API before persisting it.
|
|
21
|
+
const account = await getAccount(apiUrl, token)
|
|
22
|
+
saveConfig({ apiUrl, token })
|
|
23
|
+
success(`Logged in as ${c.bold(account.email)} on ${apiUrl}`)
|
|
24
|
+
}
|