lecodes-cli 0.20.0 → 0.20.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 +1065 -577
- package/package.json +7 -7
- 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 +179 -0
- package/runtime/materials/particles.mat +129 -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/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/assets/{createViewerLite-BvHobVhp.js → createViewerLite-Ct_PZdof.js} +22 -22
- package/runtime/web/assets/{index-CfNCCBjN.js → index-BMt7AnC5.js} +1 -1
- package/runtime/web/embed.js +2 -2
- package/src/cmgenTool.ts +17 -9
- package/src/commands/appDesktop.ts +12 -2
- package/src/commands/appShared.ts +48 -0
- package/src/commands/assets.ts +3 -7
- package/src/commands/design.ts +9 -26
- package/src/commands/desktop.ts +3 -0
- package/src/commands/dev.ts +42 -0
- package/src/commands/projectTemplates.ts +13 -1
- package/src/commands/render.ts +4 -8
- package/src/commands/scene.ts +2 -6
- package/src/commands/shaders.ts +17 -1
- package/src/commands/shadersNew.ts +133 -0
- package/src/commands/thumbs.ts +3 -6
- package/src/commands/update.ts +42 -21
- package/src/desktopRenderer.ts +17 -0
- package/src/desktopScript.ts +1 -1
- package/src/dev/androidDev.ts +154 -0
- package/src/dev/devServer.ts +8 -2
- package/src/index.ts +21 -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,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
|
+
}
|
package/src/peers.ts
CHANGED
|
@@ -1,26 +1,49 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs"
|
|
2
2
|
import { createRequire } from "node:module"
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import { dirname, join, resolve } from "node:path"
|
|
5
|
+
|
|
6
|
+
/* Optional peer packages the CLI loads lazily. Two places they can live:
|
|
7
|
+
*
|
|
8
|
+
* global `npm install -g …`, next to a globally installed CLI — the original way. A bare specifier
|
|
9
|
+
* resolves upward from the importing FILE, so a global CLI only ever sees globally installed
|
|
10
|
+
* peers (a project-local `-D` install is invisible to it).
|
|
11
|
+
* prefix ~/.lecodes/peers (LECODES_PEERS_DIR) — the CLI's own install dir, filled ON FIRST USE by
|
|
12
|
+
* peerInstall.ts when a command needs a peer that isn't there: no admin rights, and the
|
|
13
|
+
* runtime-set peer (lecodes-renderer) is pinned to THIS CLI's version, so the compiler and
|
|
14
|
+
* the headless host can't drift apart. `@napi-rs/canvas` (PNG output) rides along with it.
|
|
15
|
+
*
|
|
16
|
+
* Global wins when both exist (it is what the user chose by hand). `--version` reports each peer's
|
|
17
|
+
* installed version + where it came from, so a stale/mismatched copy — the usual cause of "needs X"
|
|
18
|
+
* errors or a design screen rendering empty — is visible at a glance. The probe is an export subpath
|
|
19
|
+
* that resolves to a real file in BOTH the published package and the workspace source. */
|
|
13
20
|
export const OPTIONAL_PEERS: [name: string, probe: string][] = [
|
|
14
21
|
["lecodes-renderer", "lecodes-renderer/headless"],
|
|
15
22
|
["lecodes-design", "lecodes-design/server"],
|
|
16
23
|
["lecodes-3d-editor", "lecodes-3d-editor/server"],
|
|
17
24
|
["lecodes-assets", "lecodes-assets/cli"],
|
|
25
|
+
["@napi-rs/canvas", "@napi-rs/canvas"],
|
|
18
26
|
]
|
|
19
27
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
/** The CLI's own peer install dir. */
|
|
29
|
+
export const peersDir = (): string =>
|
|
30
|
+
process.env.LECODES_PEERS_DIR ? resolve(process.env.LECODES_PEERS_DIR) : join(homedir(), ".lecodes", "peers")
|
|
31
|
+
|
|
32
|
+
/** Where a peer sits inside the prefix (scoped names span two directories). */
|
|
33
|
+
export const peerDirInPrefix = (name: string): string => join(peersDir(), "node_modules", ...name.split("/"))
|
|
34
|
+
|
|
35
|
+
const readVersion = (dir: string): string | null => {
|
|
36
|
+
try {
|
|
37
|
+
return (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { version?: string }).version ?? "unknown"
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Installed version of a peer as a bare specifier resolves it (global / workspace), or null. Resolves
|
|
44
|
+
* the probe subpath, then walks up to the nearest package.json that actually names the package
|
|
45
|
+
* (skipping any nested ones). Never throws — a standalone binary with no node_modules reports absent. */
|
|
46
|
+
export const peerVersionGlobal = (name: string, probe: string): string | null => {
|
|
24
47
|
try {
|
|
25
48
|
const require = createRequire(import.meta.url)
|
|
26
49
|
let dir = dirname(require.resolve(probe))
|
|
@@ -38,3 +61,14 @@ export const peerVersion = (name: string, probe: string): string | null => {
|
|
|
38
61
|
return null
|
|
39
62
|
}
|
|
40
63
|
|
|
64
|
+
/** Installed version of a peer in the CLI's own prefix, or null. */
|
|
65
|
+
export const peerVersionPrefix = (name: string): string | null => readVersion(peerDirInPrefix(name))
|
|
66
|
+
|
|
67
|
+
/** Installed version of a peer wherever it lives (global first), or null when it isn't installed. */
|
|
68
|
+
export const peerVersion = (name: string, probe: string): string | null =>
|
|
69
|
+
peerVersionGlobal(name, probe) ?? peerVersionPrefix(name)
|
|
70
|
+
|
|
71
|
+
/** Which install a bare load of the peer would hit: "global" (npm -g / the workspace), "prefix"
|
|
72
|
+
* (~/.lecodes/peers), or null when it is nowhere. */
|
|
73
|
+
export const peerSource = (name: string, probe: string): "global" | "prefix" | null =>
|
|
74
|
+
peerVersionGlobal(name, probe) !== null ? "global" : peerVersionPrefix(name) !== null ? "prefix" : null
|
package/src/projectEnv.ts
CHANGED
|
@@ -69,7 +69,11 @@ export const applyDotEnv = (
|
|
|
69
69
|
/** The nearest project root at or above `start`: the first dir with app.json or .lecodes/. */
|
|
70
70
|
export const findEnvRoot = (start: string): string | null => {
|
|
71
71
|
let dir = resolve(start)
|
|
72
|
+
// The home dir holds the CLI's own ~/.lecodes (matc, desktop host, peers) — never a project, and a
|
|
73
|
+
// `~/.env` must not be loaded as one (update.ts's findProjectDir draws the same line).
|
|
74
|
+
const home = resolve(homedir())
|
|
72
75
|
while (true) {
|
|
76
|
+
if (dir === home) return null
|
|
73
77
|
if (existsSync(join(dir, "app.json")) || existsSync(join(dir, ".lecodes"))) return dir
|
|
74
78
|
const parent = dirname(dir)
|
|
75
79
|
if (parent === dir) return null
|
package/src/releases.ts
CHANGED
|
@@ -43,6 +43,22 @@ export const semverNewer = (a: string, b: string): number => {
|
|
|
43
43
|
return 0
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The newest published version that actually carries a build for this platform — NOT the product's
|
|
48
|
+
* `latest`. A release ships the platforms whose machine ran it: matc 1.4.0 is windows-x64 alone, and
|
|
49
|
+
* its macOS build lives on 1.3.2. Keying off `latest` therefore reports "isn't published" to everyone
|
|
50
|
+
* whose platform missed the last release, even though a perfectly good older build is right there.
|
|
51
|
+
* Null when the platform has never been published at all. (These tools are deliberately version-loose:
|
|
52
|
+
* a filamat is tied to the vendored Filament, which changes rarely — see matcTool's header.)
|
|
53
|
+
*/
|
|
54
|
+
export const newestForPlatform = (
|
|
55
|
+
versions: ManifestVersion[], key: string,
|
|
56
|
+
): { version: string, file: ManifestFile } | null => {
|
|
57
|
+
const carrying = versions.filter((v) => v.files[key]).sort((a, b) => semverNewer(a.version, b.version))
|
|
58
|
+
const newest = carrying[0]
|
|
59
|
+
return newest ? { version: newest.version, file: newest.files[key]! } : null
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
/** One product from the release manifest (always fetched fresh — see MANIFEST_URL). */
|
|
47
63
|
export const fetchReleaseProduct = async (
|
|
48
64
|
product: string, label: string,
|