lecodes-cli 0.19.2 → 0.20.1
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/README.md +1 -0
- package/dist/index.js +1324 -658
- package/package.json +9 -9
- package/runtime/materials/decal-relief.mat +178 -0
- package/runtime/materials/decal.mat +172 -0
- package/runtime/materials/lightmap-baked-lite.mat +175 -0
- package/runtime/materials/lightmap-baked.mat +176 -0
- package/runtime/materials/lightmap.mat +181 -0
- package/runtime/materials/lit.mat +43 -0
- package/runtime/materials/particles-quad.mat +171 -0
- package/runtime/materials/particles.mat +121 -0
- package/runtime/materials/shadow.mat +21 -0
- package/runtime/materials/terrain.mat +134 -0
- package/runtime/materials/unlit-transparent.mat +42 -0
- package/runtime/materials/unlit.mat +44 -0
- package/runtime/materials/video.mat +31 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
- package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/runtime/web/assets/basis-C64VHDVD.js +1 -0
- package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web/assets/createViewerLite-C_bkKXLg.js +852 -0
- package/runtime/web/assets/draco-BiISTFcR.js +118 -0
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-BTB5KV9x.js +2 -0
- package/runtime/web/assets/mapViewImpl-B2JcES8l.js +810 -0
- package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +8 -0
- package/runtime/web/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
- package/runtime/web/assets/worker-Caf-yYEI.js +2 -0
- package/runtime/web/embed.html +32 -0
- package/runtime/web/embed.js +171 -0
- package/src/cmgenTool.ts +17 -9
- package/src/commands/app.ts +6 -1
- package/src/commands/appDesktop.ts +27 -4
- package/src/commands/appShared.ts +145 -3
- package/src/commands/appTemplates.ts +28 -4
- package/src/commands/assets.ts +3 -7
- package/src/commands/design.ts +9 -26
- package/src/commands/desktop.ts +9 -1
- package/src/commands/dev.ts +64 -0
- package/src/commands/projectTemplates.ts +17 -4
- package/src/commands/render.ts +9 -11
- package/src/commands/scene.ts +2 -6
- package/src/commands/shaders.ts +17 -1
- package/src/commands/shadersNew.ts +133 -0
- package/src/commands/test.ts +2 -1
- package/src/commands/thumbs.ts +3 -6
- package/src/commands/update.ts +42 -21
- package/src/desktopRenderer.ts +32 -2
- package/src/desktopScript.ts +1 -1
- package/src/dev/androidDev.ts +154 -0
- package/src/dev/devServer.ts +53 -6
- package/src/dev/webRunner.ts +22 -0
- package/src/index.ts +24 -4
- package/src/matcTool.ts +31 -19
- package/src/peerInstall.ts +160 -0
- package/src/peers.ts +48 -14
- package/src/projectEnv.ts +4 -0
- package/src/releases.ts +16 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { spawnSync } from "node:child_process"
|
|
4
|
+
import { CliError, note, warnErr } from "../util"
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* `lecodes dev --android` — open the dev URL in the LeCodes app on the connected phone, through adb.
|
|
8
|
+
*
|
|
9
|
+
* Why adb instead of "scan the QR": the phone reaches this machine over `adb reverse` (its
|
|
10
|
+
* 127.0.0.1:<port> is our port), so it works over USB with no Wi-Fi, no LAN-address guessing
|
|
11
|
+
* (WSL/Docker/VPN adapters), no firewall prompt. The launch is an EXPLICIT component intent — the
|
|
12
|
+
* app's VIEW filter is pinned to le.codes/qr, and an explicit start bypasses filter matching — with
|
|
13
|
+
* the plain dev.js url: the app's initial.js only fetches a url that ends in ".js", anything else is
|
|
14
|
+
* sent to le.codes/api/code, which answers 400 for a dev bundle (the "Произошла ошибка при открытии
|
|
15
|
+
* проекта" screen). That is also why the poll transport is a PATH (/dev-poll.js), never `?t=poll`.
|
|
16
|
+
*
|
|
17
|
+
* A device run has no frame cap (unlike the desktop host), so every exit path force-stops the app:
|
|
18
|
+
* a 3D scene left rendering is a sustained GPU load on a charging phone.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_ANDROID_PACKAGE = "com.letary.lecodes"
|
|
22
|
+
|
|
23
|
+
export type AndroidDevOptions = {
|
|
24
|
+
port: number
|
|
25
|
+
/** The device-side url to open (with `adb reverse` in place, 127.0.0.1:<port> is this machine). */
|
|
26
|
+
url: string
|
|
27
|
+
serial?: string
|
|
28
|
+
apk?: string
|
|
29
|
+
packageName?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type AndroidDevSession = {
|
|
33
|
+
serial: string
|
|
34
|
+
packageName: string
|
|
35
|
+
adb: string
|
|
36
|
+
/** `adb exec-out screencap -p` → the file. */
|
|
37
|
+
screenshot: (file: string) => boolean
|
|
38
|
+
/** force-stop the app + drop the reverse forward. Idempotent. */
|
|
39
|
+
stop: () => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const adbCandidates = (): string[] => {
|
|
43
|
+
const out: string[] = []
|
|
44
|
+
if (process.env.LECODES_ADB) out.push(process.env.LECODES_ADB)
|
|
45
|
+
out.push("adb")
|
|
46
|
+
const exe = process.platform === "win32" ? "adb.exe" : "adb"
|
|
47
|
+
for (const home of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT]) {
|
|
48
|
+
if (home) out.push(join(home, "platform-tools", exe))
|
|
49
|
+
}
|
|
50
|
+
if (process.platform === "win32" && process.env.LOCALAPPDATA) out.push(join(process.env.LOCALAPPDATA, "Android", "Sdk", "platform-tools", exe))
|
|
51
|
+
if (process.env.HOME) out.push(join(process.env.HOME, "Android", "Sdk", "platform-tools", exe), join(process.env.HOME, "Library", "Android", "sdk", "platform-tools", exe))
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const run = (adb: string, args: string[], input?: string): { ok: boolean, out: string } => {
|
|
56
|
+
const r = spawnSync(adb, args, { encoding: "utf8", input, windowsHide: true, maxBuffer: 64 * 1024 * 1024 })
|
|
57
|
+
const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim()
|
|
58
|
+
return { ok: r.status === 0, out }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The adb binary: LECODES_ADB, PATH, then the usual SDK locations. */
|
|
62
|
+
export const resolveAdb = (): string => {
|
|
63
|
+
for (const cand of adbCandidates()) {
|
|
64
|
+
if (cand !== "adb" && !existsSync(cand)) continue
|
|
65
|
+
const r = spawnSync(cand, ["version"], { encoding: "utf8", windowsHide: true })
|
|
66
|
+
if (r.status === 0) return cand
|
|
67
|
+
}
|
|
68
|
+
throw new CliError("adb not found — install Android platform-tools (or point LECODES_ADB at adb).")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Connected devices in the `device` state (unauthorized/offline ones are reported, not used). */
|
|
72
|
+
export const listDevices = (adb: string): { serial: string, state: string, model?: string }[] => {
|
|
73
|
+
const { out } = run(adb, ["devices", "-l"])
|
|
74
|
+
const devices: { serial: string, state: string, model?: string }[] = []
|
|
75
|
+
for (const line of out.split(/\r?\n/).slice(1)) {
|
|
76
|
+
const m = /^(\S+)\s+(\S+)(.*)$/.exec(line.trim())
|
|
77
|
+
if (!m) continue
|
|
78
|
+
const model = /model:(\S+)/.exec(m[3] ?? "")?.[1]
|
|
79
|
+
devices.push({ serial: m[1], state: m[2], model })
|
|
80
|
+
}
|
|
81
|
+
return devices
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pickDevice = (adb: string, serial: string | undefined): string => {
|
|
85
|
+
const all = listDevices(adb)
|
|
86
|
+
if (serial) {
|
|
87
|
+
const d = all.find((x) => x.serial === serial)
|
|
88
|
+
if (!d) throw new CliError(`No device "${serial}" — adb sees: ${all.map((x) => `${x.serial} (${x.state})`).join(", ") || "nothing"}.`)
|
|
89
|
+
if (d.state !== "device") throw new CliError(`Device ${serial} is "${d.state}" — accept the USB-debugging prompt on the phone.`)
|
|
90
|
+
return serial
|
|
91
|
+
}
|
|
92
|
+
const ready = all.filter((d) => d.state === "device")
|
|
93
|
+
if (ready.length === 1) return ready[0].serial
|
|
94
|
+
if (ready.length === 0) {
|
|
95
|
+
const other = all.map((d) => `${d.serial} (${d.state})`).join(", ")
|
|
96
|
+
throw new CliError(other
|
|
97
|
+
? `No usable device: adb sees ${other}. "unauthorized" = accept the USB-debugging prompt on the phone; "offline" = replug.`
|
|
98
|
+
: "No Android device: enable USB debugging (Developer options), plug the phone in, accept the prompt — or `adb connect <ip>:5555` for Wi-Fi debugging.")
|
|
99
|
+
}
|
|
100
|
+
throw new CliError(`Several devices: ${ready.map((d) => `${d.serial}${d.model ? ` (${d.model})` : ""}`).join(", ")} — pick one with --serial <id>.`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Install (optional), forward the port, launch the app on the dev url. Returns the session handle. */
|
|
104
|
+
export const launchOnAndroid = (opts: AndroidDevOptions): AndroidDevSession => {
|
|
105
|
+
const adb = resolveAdb()
|
|
106
|
+
const serial = pickDevice(adb, opts.serial)
|
|
107
|
+
const packageName = opts.packageName ?? process.env.LECODES_ANDROID_PACKAGE ?? DEFAULT_ANDROID_PACKAGE
|
|
108
|
+
const dev = (args: string[]) => run(adb, ["-s", serial, ...args])
|
|
109
|
+
|
|
110
|
+
if (opts.apk) {
|
|
111
|
+
if (!existsSync(opts.apk)) throw new CliError(`--apk: ${opts.apk} does not exist.`)
|
|
112
|
+
note(`Installing ${opts.apk}…`)
|
|
113
|
+
const r = dev(["install", "-r", opts.apk])
|
|
114
|
+
if (!r.ok || !/Success/.test(r.out)) {
|
|
115
|
+
throw new CliError(`adb install failed: ${r.out}${/USER_RESTRICTED/.test(r.out) ? "\n(MIUI: enable \"Install via USB\" in Developer options.)" : ""}`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const installed = dev(["shell", "pm", "path", packageName])
|
|
119
|
+
if (!installed.ok || !installed.out.includes("package:")) {
|
|
120
|
+
throw new CliError(`${packageName} is not installed on ${serial} — pass --apk <file> (lecodes-android: .\\gradlew :app:assembleDebug → app/build/outputs/apk/debug/app-debug.apk).`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The phone's 127.0.0.1:<port> → this machine's dev server. Survives Wi-Fi changes and VPNs.
|
|
124
|
+
const rev = dev(["reverse", `tcp:${opts.port}`, `tcp:${opts.port}`])
|
|
125
|
+
if (!rev.ok) warnErr(`adb reverse failed (${rev.out}) — the phone must reach the LAN url instead.`)
|
|
126
|
+
|
|
127
|
+
dev(["shell", "am", "force-stop", packageName])
|
|
128
|
+
dev(["logcat", "-c"])
|
|
129
|
+
const start = dev(["shell", "am", "start", "-n", `${packageName}/.MainActivity`, "-a", "android.intent.action.VIEW", "-d", opts.url])
|
|
130
|
+
if (!start.ok || /Error/.test(start.out)) throw new CliError(`Could not launch ${packageName}: ${start.out}`)
|
|
131
|
+
|
|
132
|
+
let stopped = false
|
|
133
|
+
return {
|
|
134
|
+
serial, packageName, adb,
|
|
135
|
+
screenshot: (file: string) => {
|
|
136
|
+
const r = spawnSync(adb, ["-s", serial, "exec-out", "screencap", "-p"], { windowsHide: true, maxBuffer: 64 * 1024 * 1024 })
|
|
137
|
+
if (r.status !== 0 || !r.stdout || r.stdout.length === 0) return false
|
|
138
|
+
writeFileSync(file, r.stdout)
|
|
139
|
+
return true
|
|
140
|
+
},
|
|
141
|
+
stop: () => {
|
|
142
|
+
if (stopped) return
|
|
143
|
+
stopped = true
|
|
144
|
+
dev(["shell", "am", "force-stop", packageName])
|
|
145
|
+
dev(["reverse", "--remove", `tcp:${opts.port}`])
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Crash lines for the app since the launch (logcat was cleared then): the native + Java fatals. */
|
|
151
|
+
export const androidCrashLines = (session: AndroidDevSession): string[] => {
|
|
152
|
+
const { out } = run(session.adb, ["-s", session.serial, "logcat", "-d", "-v", "brief"])
|
|
153
|
+
return out.split(/\r?\n/).filter((l) => /FATAL EXCEPTION|Fatal signal|\[crash\]/.test(l) || (/AndroidRuntime/.test(l) && l.includes(session.packageName))).slice(-20)
|
|
154
|
+
}
|
package/src/dev/devServer.ts
CHANGED
|
@@ -24,6 +24,9 @@ import { makeStackDemangler } from "../compile/nativeStack"
|
|
|
24
24
|
* GET /t/<tok>/version {"v":N} — polling fallback
|
|
25
25
|
* POST /t/<tok>/log log/error backchannel for polling clients
|
|
26
26
|
* WS /t/<tok>/ws reload push + log/error backchannel
|
|
27
|
+
* GET /t/<tok>/web/[file] the Web Lite browser runner (viewer-lite dist-embed, `dev --web`):
|
|
28
|
+
* it loads bundle.js?t=web — the bundle WITHOUT the native prelude;
|
|
29
|
+
* the page owns reload (ws / version polling) and the log backchannel
|
|
27
30
|
*
|
|
28
31
|
* Compile is eager: the command calls recompile() on every file change; a successful compile bumps
|
|
29
32
|
* `v` and broadcasts {type:"reload", v} to connected sockets (polling clients see /version move).
|
|
@@ -41,6 +44,8 @@ export type DevServerOptions = {
|
|
|
41
44
|
port: number
|
|
42
45
|
token: string
|
|
43
46
|
entryOverride?: string
|
|
47
|
+
/** The Web Lite runner folder to serve under /web/ (src/dev/webRunner.ts); absent = no browser route. */
|
|
48
|
+
webDir?: string | null
|
|
44
49
|
/** Diagnostics (compile errors, device logs); defaults to stderr-style silence-safe noop. */
|
|
45
50
|
log?: (msg: string) => void
|
|
46
51
|
}
|
|
@@ -51,6 +56,8 @@ export type RecompileResult =
|
|
|
51
56
|
|
|
52
57
|
export type DevServer = {
|
|
53
58
|
url: string
|
|
59
|
+
/** The browser runner page (`dev --web`), or null when no runner is vendored. */
|
|
60
|
+
webUrl: string | null
|
|
54
61
|
/** The token base path ("/t/<token>"). */
|
|
55
62
|
base: string
|
|
56
63
|
/** The actually-bound port (differs from options.port when 0 was passed, e.g. in tests). */
|
|
@@ -68,7 +75,8 @@ const MIME: Record<string, string> = {
|
|
|
68
75
|
mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4",
|
|
69
76
|
mp4: "video/mp4", webm: "video/webm",
|
|
70
77
|
ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2",
|
|
71
|
-
json: "application/json", txt: "text/plain", js: "text/javascript",
|
|
78
|
+
json: "application/json", txt: "text/plain", js: "text/javascript", mjs: "text/javascript",
|
|
79
|
+
css: "text/css", html: "text/html", wasm: "application/wasm", map: "application/json",
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
const contentType = (path: string): string => {
|
|
@@ -182,22 +190,60 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
|
|
|
182
190
|
}
|
|
183
191
|
|
|
184
192
|
// ---- http --------------------------------------------------------------------------------------
|
|
185
|
-
|
|
193
|
+
// "web" = the browser runner: no prelude at all (the page brings its own reload + log channel).
|
|
194
|
+
const transportOf = (url: URL): "ws" | "poll" | "web" => {
|
|
195
|
+
const t = url.searchParams.get("t")
|
|
196
|
+
return t === "poll" ? "poll" : t === "web" ? "web" : "ws"
|
|
197
|
+
}
|
|
198
|
+
const withPrelude = (js: string, url: URL, v: number, forced?: "poll") => {
|
|
199
|
+
const transport = forced ?? transportOf(url)
|
|
200
|
+
return transport === "web" ? js : js + "\n" + renderPrelude({ origin, base, transport, v })
|
|
201
|
+
}
|
|
202
|
+
const webDir = options.webDir ?? null
|
|
186
203
|
|
|
187
204
|
const handle = async (req: IncomingMessage, res: ServerResponse) => {
|
|
188
205
|
const url = new URL(req.url ?? "/", origin)
|
|
189
206
|
const path = url.pathname
|
|
207
|
+
// Browsers ask for it on every page load (the /web/ runner) — keep the console clean.
|
|
208
|
+
if (path === "/favicon.ico") { res.writeHead(204); res.end(); return }
|
|
190
209
|
if (!path.startsWith(base + "/")) return sendText(res, 404, "text/plain", "Not found")
|
|
191
210
|
const route = path.slice(base.length)
|
|
192
211
|
|
|
193
212
|
if (route === "/dev.js") {
|
|
194
|
-
return sendText(res, 200, "text/javascript",
|
|
195
|
-
|
|
213
|
+
return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1))
|
|
214
|
+
}
|
|
215
|
+
// The poll transport as a PATH: the Android app's initial.js only fetches a launch url that ends
|
|
216
|
+
// in ".js" (anything else goes to le.codes/api/code), so `?t=poll` can't reach it. Same bundle,
|
|
217
|
+
// prelude polls instead of the websocket — what `dev --android` opens under Bun (no ws there).
|
|
218
|
+
if (route === "/dev-poll.js") {
|
|
219
|
+
return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1, "poll"))
|
|
196
220
|
}
|
|
197
221
|
if (route === "/bundle.js") {
|
|
198
222
|
if (bundleJs === null) return sendText(res, 503, "text/plain", "No successful compile yet — check the terminal.")
|
|
199
|
-
|
|
200
|
-
|
|
223
|
+
res.setHeader("x-lecodes-v", String(v)) // the browser runner learns the version it runs
|
|
224
|
+
return sendText(res, 200, "text/javascript", withPrelude(bundleJs, url, v))
|
|
225
|
+
}
|
|
226
|
+
// The browser runner: a static folder with relative asset paths, so it needs the trailing slash.
|
|
227
|
+
if (route === "/web") {
|
|
228
|
+
res.writeHead(302, { location: `${base}/web/${url.search}` })
|
|
229
|
+
res.end()
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
if (route.startsWith("/web/")) {
|
|
233
|
+
if (!webDir) return sendText(res, 404, "text/plain", "No Web Lite runner vendored with this CLI (lecodes-viewer-lite dist-embed).")
|
|
234
|
+
const rel = route === "/web/" ? "embed.html" : route.slice("/web/".length)
|
|
235
|
+
if (rel.split("/").some((seg) => seg === ".." || seg === "" || seg === ".")) return sendText(res, 400, "text/plain", "Bad path")
|
|
236
|
+
const abs = join(webDir, ...rel.split("/"))
|
|
237
|
+
try {
|
|
238
|
+
if (!statSync(abs).isFile()) throw new Error("not a file")
|
|
239
|
+
} catch {
|
|
240
|
+
return sendText(res, 404, "text/plain", "Not found")
|
|
241
|
+
}
|
|
242
|
+
// The entry files (html, embed.js) must revalidate — a CLI update swaps them under the same URL.
|
|
243
|
+
const hashed = rel.startsWith("assets/")
|
|
244
|
+
res.writeHead(200, { "content-type": contentType(rel), "cache-control": hashed ? "max-age=31536000, immutable" : "no-store" })
|
|
245
|
+
createReadStream(abs).pipe(res)
|
|
246
|
+
return
|
|
201
247
|
}
|
|
202
248
|
if (route === "/version") {
|
|
203
249
|
return sendJson(res, 200, { v })
|
|
@@ -279,6 +325,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
|
|
|
279
325
|
|
|
280
326
|
return {
|
|
281
327
|
url: `${origin}${base}/dev.js`,
|
|
328
|
+
webUrl: webDir ? `${origin}${base}/web/` : null,
|
|
282
329
|
base,
|
|
283
330
|
port: boundPort,
|
|
284
331
|
v: () => v,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { createRequire } from "node:module"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { distRoot } from "../distRoot"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where the browser runner for `lecodes dev --web` lives: viewer-lite's built `dist-embed`
|
|
8
|
+
* (embed.html + embed.js + assets/ — the Web Lite host as a static folder). Two shapes:
|
|
9
|
+
* - vendored next to the CLI as runtime/web (scripts/vendor-runtime.ts — the npm package and
|
|
10
|
+
* the standalone binary),
|
|
11
|
+
* - the lecodes-viewer-lite package's own dist-embed (monorepo dev after `bun run build:embed`).
|
|
12
|
+
* `null` = no runner available (the dev server then serves everything but /web/).
|
|
13
|
+
*/
|
|
14
|
+
export const resolveWebRunnerDir = (): string | null => {
|
|
15
|
+
const candidates = [join(distRoot, "runtime", "web")]
|
|
16
|
+
try {
|
|
17
|
+
const pkg = createRequire(import.meta.url).resolve("lecodes-viewer-lite/package.json")
|
|
18
|
+
candidates.push(join(dirname(pkg), "dist-embed"))
|
|
19
|
+
} catch { /* no package: standalone */ }
|
|
20
|
+
for (const dir of candidates) if (existsSync(join(dir, "embed.html")) && existsSync(join(dir, "embed.js"))) return dir
|
|
21
|
+
return null
|
|
22
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { CliError, c, log, parseArgs, type Args } from "./util"
|
|
|
31
31
|
import { installedDesktopVersions, selectedDesktopVersion } from "./desktopRenderer"
|
|
32
32
|
import { installedMatcVersions } from "./matcTool"
|
|
33
33
|
import { loadProjectEnv } from "./projectEnv"
|
|
34
|
-
import { OPTIONAL_PEERS, peerVersion } from "./peers"
|
|
34
|
+
import { OPTIONAL_PEERS, peerSource, peerVersion } from "./peers"
|
|
35
35
|
|
|
36
36
|
/* `install` is a standalone-binary-only command: it installs the shipped bundle onto PATH, so it's only
|
|
37
37
|
* meaningful when that bundle actually sits next to the executable — the `chisel` bundler + `runtime/`
|
|
@@ -120,6 +120,17 @@ ${c.bold("Commands:")}
|
|
|
120
120
|
--host <ip> LAN address to advertise (default: auto-detect)
|
|
121
121
|
--entry <path> override the entrypoint
|
|
122
122
|
--no-qr don't render the QR code
|
|
123
|
+
--android open the dev url in the LeCodes app on the phone
|
|
124
|
+
plugged in over adb (adb reverse: no Wi-Fi/LAN
|
|
125
|
+
address needed). --serial <id> picks a device,
|
|
126
|
+
--apk <file> installs first, --package <name>
|
|
127
|
+
(default com.letary.lecodes). Device logs stream
|
|
128
|
+
here. --time <s> --png <file>: bounded check —
|
|
129
|
+
screenshot after <s> seconds, force-stop, exit
|
|
130
|
+
(exit 1 on a crash). Ctrl+C force-stops the app.
|
|
131
|
+
--web also open the app in the browser (Web Lite host:
|
|
132
|
+
same bundle + hot reload, camera/map plugins
|
|
133
|
+
have web twins; the /web/ URL is served always)
|
|
123
134
|
--desktop also open the app in the native desktop host
|
|
124
135
|
(same hot reload; window from app.json's
|
|
125
136
|
desktop block; closing it keeps the server)
|
|
@@ -312,12 +323,15 @@ ${c.bold("Commands:")}
|
|
|
312
323
|
--cell <m> (0.25) --cell-height <m> (0.1) --tile <cells> (48)
|
|
313
324
|
--obj also write <n>.navmesh.obj to inspect
|
|
314
325
|
--frames <n> load headroom (900) --logs host output
|
|
315
|
-
shaders [compile|update]
|
|
326
|
+
shaders [compile|update|new] The project's custom shaders (.mat) and their local compile:
|
|
316
327
|
no args lists the shaders + their compile state; compile
|
|
317
328
|
compiles them all into .lecodes/shaders (the same pass
|
|
318
329
|
'lecodes dev' runs on the fly — via Filament's matc,
|
|
319
330
|
downloaded on first use to ~/.lecodes/matc); update
|
|
320
|
-
downloads the newest matc (run after an engine upgrade)
|
|
331
|
+
downloads the newest matc (run after an engine upgrade);
|
|
332
|
+
new <name> [--ref lit] [--dir d] [--force] writes a fresh
|
|
333
|
+
.mat + .material.ts: the starter, or a copy of the engine's
|
|
334
|
+
own lit / unlit / particles / … source to start from
|
|
321
335
|
scene [path] LeCodes Scene Editor — visually edit a .scene.ts (tree, inspector,
|
|
322
336
|
gizmo) on local files (needs the optional 'lecodes-3d-editor' package;
|
|
323
337
|
works inside a cloned project or standalone in any folder)
|
|
@@ -443,7 +457,10 @@ const printVersion = () => {
|
|
|
443
457
|
const rows: [label: string, value: string][] = [["lecodes-cli", readVersion()]]
|
|
444
458
|
for (const [name, probe] of OPTIONAL_PEERS) {
|
|
445
459
|
const version = peerVersion(name, probe)
|
|
446
|
-
|
|
460
|
+
const where = peerSource(name, probe)
|
|
461
|
+
rows.push([name, version
|
|
462
|
+
? (where === "prefix" ? `${version} ${c.dim("(~/.lecodes/peers)")}` : version)
|
|
463
|
+
: c.dim("not installed — installed on first use")])
|
|
447
464
|
}
|
|
448
465
|
const desktopVersion = selectedDesktopVersion()
|
|
449
466
|
rows.push(["lecodes-desktop", installedDesktopVersions().includes(desktopVersion)
|
|
@@ -461,6 +478,9 @@ const main = async () => {
|
|
|
461
478
|
// The project's .env (LECODES_* / CREATOR_* next to app.json) — before any command reads the
|
|
462
479
|
// environment; the real environment keeps precedence. See projectEnv.ts.
|
|
463
480
|
loadProjectEnv()
|
|
481
|
+
// `--yes` / `-y` anywhere on the line also pre-approves first-use installs of optional peers
|
|
482
|
+
// (peerInstall.ts) — the same knob LECODES_YES=1 gives CI.
|
|
483
|
+
if (argv.includes("--yes") || argv.includes("-y")) process.env.LECODES_YES = "1"
|
|
464
484
|
|
|
465
485
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
466
486
|
log(HELP)
|
package/src/matcTool.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
|
-
import { downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
|
|
4
|
+
import { downloadReleaseArchive, fetchReleaseProduct, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
|
|
5
5
|
import { CliError, note } from "./util"
|
|
6
6
|
|
|
7
7
|
/*
|
|
@@ -16,8 +16,11 @@ import { CliError, note } from "./util"
|
|
|
16
16
|
* hosts ship). Cache: ~/.lecodes/matc/matc-<version>/. LECODES_MATC overrides resolution entirely
|
|
17
17
|
* (local dev: point it at packages/desktop/build/creator-gl/filament/tools/matc/matc.exe).
|
|
18
18
|
*
|
|
19
|
-
* No version pin: a filamat's compatibility is tied to the vendored Filament, which changes
|
|
20
|
-
*
|
|
19
|
+
* No version pin: a filamat's compatibility is tied to the vendored Filament, which changes rarely —
|
|
20
|
+
* resolution takes the newest cached copy, else downloads the newest release PUBLISHED FOR THIS
|
|
21
|
+
* PLATFORM (releases.ts newestForPlatform), NOT the product's `latest`: a release only ships the
|
|
22
|
+
* platforms whose machine ran it, so `latest` is often Windows-only and every other platform would
|
|
23
|
+
* read "not published" with a perfectly good older build sitting right there. `lecodes shaders
|
|
21
24
|
* update` moves an existing cache forward after an engine upgrade.
|
|
22
25
|
*
|
|
23
26
|
* Platforms: windows-x64, linux-x64, macos-arm64. On Windows/Linux matc rides the DESKTOP build
|
|
@@ -80,15 +83,21 @@ export const installedMatcVersions = (): string[] => {
|
|
|
80
83
|
export const fetchMatcManifest = (): Promise<{ latest: string, versions: ManifestVersion[] }> =>
|
|
81
84
|
fetchReleaseProduct("matc", "matc (shader compiler)")
|
|
82
85
|
|
|
83
|
-
|
|
86
|
+
/** The published matc this platform should get: the newest version carrying a build for it (see
|
|
87
|
+
* newestForPlatform — a release often ships one platform, so `latest` is the wrong question). */
|
|
88
|
+
const pickMatc = (versions: ManifestVersion[]): { version: string, file: ManifestFile } => {
|
|
84
89
|
const key = platformKey()
|
|
85
|
-
const
|
|
86
|
-
if (!
|
|
90
|
+
const hit = newestForPlatform(versions, key)
|
|
91
|
+
if (!hit) {
|
|
87
92
|
throw new CliError(
|
|
88
|
-
`matc
|
|
93
|
+
`matc isn't published for ${key} — push the project once so shaders compile on the ` +
|
|
89
94
|
`server, or set LECODES_MATC to a locally built matc.`,
|
|
90
95
|
)
|
|
91
96
|
}
|
|
97
|
+
return hit
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const downloadMatc = async (version: string, file: ManifestFile, dest: string): Promise<void> => {
|
|
92
101
|
note(`Downloading the shader compiler (matc ${version}, ${(file.size / 1024 / 1024).toFixed(1)} MB)…`)
|
|
93
102
|
await downloadReleaseArchive(file, {
|
|
94
103
|
cacheRoot: cacheRoot(),
|
|
@@ -109,29 +118,32 @@ export const resolveMatcExeCached = (): string | null => {
|
|
|
109
118
|
return cached ? findMatc(join(cacheRoot(), `matc-${cached}`)) : null
|
|
110
119
|
}
|
|
111
120
|
|
|
112
|
-
/** The matc binary: LECODES_MATC override → newest cached version → download the
|
|
113
|
-
* Throws CliError when unavailable (callers degrade to the push-once
|
|
121
|
+
/** The matc binary: LECODES_MATC override → newest cached version → download the newest release
|
|
122
|
+
* published for this platform. Throws CliError when unavailable (callers degrade to the push-once
|
|
123
|
+
* server-compile warning). */
|
|
114
124
|
export const resolveMatcExe = async (): Promise<string> => {
|
|
115
125
|
const cached = resolveMatcExeCached()
|
|
116
126
|
if (cached) return cached
|
|
117
|
-
const {
|
|
118
|
-
const
|
|
119
|
-
|
|
127
|
+
const { versions } = await fetchMatcManifest()
|
|
128
|
+
const { version, file } = pickMatc(versions)
|
|
129
|
+
const dir = join(cacheRoot(), `matc-${version}`)
|
|
130
|
+
await downloadMatc(version, file, dir)
|
|
120
131
|
const exe = findMatc(dir)
|
|
121
132
|
if (!exe) throw new CliError(`matc cache at ${dir} is broken — delete the folder and retry.`)
|
|
122
133
|
return exe
|
|
123
134
|
}
|
|
124
135
|
|
|
125
|
-
/** `lecodes shaders update`: download the newest published
|
|
126
|
-
* wins resolution). Run it after an engine upgrade so local
|
|
136
|
+
/** `lecodes shaders update`: download the newest matc published for this platform (cached versions
|
|
137
|
+
* stay; the newest wins resolution). Run it after an engine upgrade so local artifacts match the hosts. */
|
|
127
138
|
export const updateMatc = async (): Promise<void> => {
|
|
128
|
-
const {
|
|
139
|
+
const { versions } = await fetchMatcManifest()
|
|
140
|
+
const { version, file } = pickMatc(versions)
|
|
129
141
|
const before = installedMatcVersions()[0]
|
|
130
|
-
const dir = join(cacheRoot(), `matc-${
|
|
142
|
+
const dir = join(cacheRoot(), `matc-${version}`)
|
|
131
143
|
if (existsSync(dir) && findMatc(dir) !== null) {
|
|
132
|
-
note(`Already up to date: matc ${
|
|
144
|
+
note(`Already up to date: matc ${version}.`)
|
|
133
145
|
return
|
|
134
146
|
}
|
|
135
|
-
await downloadMatc(
|
|
136
|
-
note(before ? `matc updated to ${
|
|
147
|
+
await downloadMatc(version, file, dir)
|
|
148
|
+
note(before ? `matc updated to ${version} (was ${before}).` : `matc ${version} installed.`)
|
|
137
149
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { pathToFileURL } from "node:url"
|
|
5
|
+
import pkg from "../package.json"
|
|
6
|
+
import { peerDirInPrefix, peersDir, peerVersionGlobal } from "./peers"
|
|
7
|
+
import { CliError, c, confirm, log, note } from "./util"
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* Peers install themselves on first use. `lecodes render` on a machine with only the CLI asks once,
|
|
11
|
+
* then puts lecodes-renderer (+ @napi-rs/canvas for PNG) into ~/.lecodes/peers and loads it from
|
|
12
|
+
* there — the same pattern as matc and the desktop host (downloaded into ~/.lecodes on first use),
|
|
13
|
+
* applied to npm packages. Why a private prefix and not `npm install -g`: no sudo / system prefix
|
|
14
|
+
* to guess, and the runtime-set peer is installed at EXACTLY this CLI's version — the compiler and the
|
|
15
|
+
* headless host are one ABI set, so a global renderer that drifted behind the CLI is the very failure
|
|
16
|
+
* this removes. A newer CLI re-installs its own version on the next use (peerSpec → `lecodes update`).
|
|
17
|
+
*
|
|
18
|
+
* Consent: an interactive terminal is asked (default yes); `--yes` / `-y` on any command or
|
|
19
|
+
* LECODES_YES=1 (CI) skips the question; a non-interactive run without either fails with the exact
|
|
20
|
+
* command to run by hand. Installer: npm if on PATH, else bun; neither → a clear error (the standalone
|
|
21
|
+
* binary on a machine without Node can't install packages — it says so).
|
|
22
|
+
*
|
|
23
|
+
* Search order when a command needs a peer: the bare specifier (global / the workspace — what the
|
|
24
|
+
* user installed by hand always wins) → the prefix → install into the prefix, then load from there.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const CANVAS_RANGE_FALLBACK = "^0.1.65"
|
|
28
|
+
|
|
29
|
+
const readJson = (path: string): any => { try { return JSON.parse(readFileSync(path, "utf8")) } catch { return null } }
|
|
30
|
+
|
|
31
|
+
/** The npm spec a peer installs at: the renderer at exactly this CLI's version (one ABI set), canvas
|
|
32
|
+
* at the range the installed renderer declares, the editors / assets at this CLI's peer ranges. */
|
|
33
|
+
export const peerSpec = (name: string): string => {
|
|
34
|
+
if (name === "lecodes-renderer") return `lecodes-renderer@${pkg.version}`
|
|
35
|
+
if (name === "@napi-rs/canvas") {
|
|
36
|
+
const renderer = readJson(join(peerDirInPrefix("lecodes-renderer"), "package.json"))
|
|
37
|
+
const range = renderer?.peerDependencies?.["@napi-rs/canvas"] ?? renderer?.optionalDependencies?.["@napi-rs/canvas"] ?? CANVAS_RANGE_FALLBACK
|
|
38
|
+
return `@napi-rs/canvas@${range}`
|
|
39
|
+
}
|
|
40
|
+
const range = (pkg.peerDependencies as Record<string, string> | undefined)?.[name]
|
|
41
|
+
return range ? `${name}@${range}` : `${name}@latest`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Peers that only make sense together: the renderer's PNG output needs canvas next to it. */
|
|
45
|
+
const companions = (name: string): string[] => name === "lecodes-renderer" ? ["lecodes-renderer", "@napi-rs/canvas"] : [name]
|
|
46
|
+
|
|
47
|
+
const onPath = (cmd: string): boolean => {
|
|
48
|
+
try { return spawnSync(cmd, ["--version"], { stdio: "ignore", shell: process.platform === "win32" }).status === 0 } catch { return false }
|
|
49
|
+
}
|
|
50
|
+
const pickInstaller = (): "npm" | "bun" | null => onPath("npm") ? "npm" : onPath("bun") ? "bun" : null
|
|
51
|
+
|
|
52
|
+
const manualCommand = (dir: string, specs: string[]) => `npm install --prefix "${dir}" ${specs.join(" ")}`
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Install peers into the prefix. Asks for consent unless `opts.yes` / LECODES_YES / `--yes` say so;
|
|
56
|
+
* throws CliError with the manual command when it can't ask or the user declines.
|
|
57
|
+
*/
|
|
58
|
+
export const installPeers = async (names: string[], why: string, opts: { yes?: boolean } = {}): Promise<void> => {
|
|
59
|
+
const dir = peersDir()
|
|
60
|
+
mkdirSync(dir, { recursive: true })
|
|
61
|
+
const manifest = join(dir, "package.json")
|
|
62
|
+
if (!existsSync(manifest)) {
|
|
63
|
+
writeFileSync(manifest, JSON.stringify({
|
|
64
|
+
name: "lecodes-peers", private: true,
|
|
65
|
+
description: "Optional lecodes-cli peers, installed on first use. Safe to delete — the CLI re-installs what a command needs.",
|
|
66
|
+
}, null, 2) + "\n")
|
|
67
|
+
}
|
|
68
|
+
// The renderer must be in place before canvas's range can be read off it — install in order.
|
|
69
|
+
const ordered = [...names].sort((a, b) => (a === "lecodes-renderer" ? -1 : b === "lecodes-renderer" ? 1 : 0))
|
|
70
|
+
const specsNow = ordered.map(peerSpec)
|
|
71
|
+
|
|
72
|
+
const yes = opts.yes === true || !!process.env.LECODES_YES
|
|
73
|
+
if (!yes) {
|
|
74
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY
|
|
75
|
+
if (!interactive) {
|
|
76
|
+
throw new CliError(`${why} needs ${names.join(" + ")}, which isn't installed. Re-run with --yes to install it into ${dir}, or install by hand: ${manualCommand(dir, specsNow)}`)
|
|
77
|
+
}
|
|
78
|
+
log(`${why} needs ${c.bold(names.join(" + "))}.`)
|
|
79
|
+
log(c.dim(`It installs once into ${dir} (no admin rights); a newer CLI re-installs its own version.`))
|
|
80
|
+
if (!(await confirm("Install now?", true))) {
|
|
81
|
+
throw new CliError(`Not installed. When you want it: ${manualCommand(dir, specsNow)}`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const tool = pickInstaller()
|
|
86
|
+
if (!tool) throw new CliError(`Neither npm nor bun is on PATH, so ${names.join(" + ")} can't be installed automatically. Install one, or run on a machine with Node: ${manualCommand(dir, specsNow)}`)
|
|
87
|
+
|
|
88
|
+
for (const name of ordered) {
|
|
89
|
+
const spec = peerSpec(name) // canvas's range comes from the renderer installed a step earlier
|
|
90
|
+
const argv = tool === "npm"
|
|
91
|
+
? ["install", "--prefix", dir, "--no-audit", "--no-fund", "--loglevel=error", spec]
|
|
92
|
+
: ["add", "--cwd", dir, spec]
|
|
93
|
+
note(`$ ${tool} ${argv.join(" ")}`)
|
|
94
|
+
const r = spawnSync(tool, argv, { stdio: "inherit", shell: process.platform === "win32", timeout: 10 * 60_000 })
|
|
95
|
+
if (r.error) throw new CliError(`${tool} could not run: ${r.error.message}`)
|
|
96
|
+
if (r.status !== 0) throw new CliError(`${tool} exited with ${r.status ?? "a signal"} while installing ${spec}. By hand: ${manualCommand(dir, [spec])}`)
|
|
97
|
+
if (!existsSync(join(peerDirInPrefix(name), "package.json"))) throw new CliError(`${name} did not land in ${dir} — the installer finished but the package is missing`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The file a package subpath points at, read from its exports map (string, or a conditions object —
|
|
102
|
+
* `import` / `node` / `default`), falling back to `main`. Absolute path. */
|
|
103
|
+
const resolveEntry = (pkgDir: string, subpath?: string): string => {
|
|
104
|
+
const manifest = readJson(join(pkgDir, "package.json"))
|
|
105
|
+
if (!manifest) throw new CliError(`${pkgDir} holds no package.json`)
|
|
106
|
+
const key = subpath ? `./${subpath}` : "."
|
|
107
|
+
const pick = (target: unknown): string | null => {
|
|
108
|
+
if (typeof target === "string") return target
|
|
109
|
+
if (target && typeof target === "object") {
|
|
110
|
+
const t = target as Record<string, unknown>
|
|
111
|
+
for (const cond of ["import", "node", "default", "require"]) if (cond in t) { const p = pick(t[cond]); if (p) return p }
|
|
112
|
+
}
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
let rel: string | null = null
|
|
116
|
+
const exp = manifest.exports
|
|
117
|
+
if (typeof exp === "string") rel = key === "." ? exp : null
|
|
118
|
+
else if (exp && typeof exp === "object") rel = pick((exp as Record<string, unknown>)[key])
|
|
119
|
+
if (!rel && key === ".") rel = typeof manifest.main === "string" ? manifest.main : "index.js"
|
|
120
|
+
if (!rel) throw new CliError(`${manifest.name} has no export "${key}"`)
|
|
121
|
+
return join(pkgDir, rel)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const isNotFound = (e: unknown, spec: string): boolean => {
|
|
125
|
+
const code = (e as { code?: string })?.code
|
|
126
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
127
|
+
return (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || /Cannot find (module|package)/i.test(msg)) && msg.includes(spec.split("/")[0]!)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type LoadPeerOptions = {
|
|
131
|
+
/** What is asking — names the command in the consent prompt / error ("lecodes render"). */
|
|
132
|
+
for: string
|
|
133
|
+
/** `false`: never install — return null when the peer is nowhere (opportunistic features). */
|
|
134
|
+
install?: boolean
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Load an optional peer's subpath: the bare specifier first (global / workspace), then the CLI's
|
|
139
|
+
* prefix, else install it there (with its companions) and load. Returns null only with
|
|
140
|
+
* `install: false` and the peer absent.
|
|
141
|
+
*/
|
|
142
|
+
export async function loadPeer<T>(name: string, subpath: string | undefined, opts: LoadPeerOptions & { install?: true }): Promise<T>
|
|
143
|
+
export async function loadPeer<T>(name: string, subpath: string | undefined, opts: LoadPeerOptions & { install: false }): Promise<T | null>
|
|
144
|
+
export async function loadPeer<T>(name: string, subpath: string | undefined, opts: LoadPeerOptions): Promise<T | null> {
|
|
145
|
+
const spec = subpath ? `${name}/${subpath}` : name
|
|
146
|
+
try {
|
|
147
|
+
return (await import(spec)) as T
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (!isNotFound(e, spec)) throw e
|
|
150
|
+
}
|
|
151
|
+
const dir = peerDirInPrefix(name)
|
|
152
|
+
if (!existsSync(join(dir, "package.json"))) {
|
|
153
|
+
if (opts.install === false) return null
|
|
154
|
+
// A global install of a DIFFERENT version than the probe expects would have resolved above; here
|
|
155
|
+
// the peer is nowhere. (Globally installed but broken → the error above was not "not found".)
|
|
156
|
+
if (peerVersionGlobal(name, spec) !== null) throw new CliError(`${name} is installed but "${spec}" can't be loaded from it — reinstall it: npm install -g ${peerSpec(name)}`)
|
|
157
|
+
await installPeers(companions(name), opts.for)
|
|
158
|
+
}
|
|
159
|
+
return (await import(pathToFileURL(resolveEntry(dir, subpath)).href)) as T
|
|
160
|
+
}
|