lecodes-cli 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.js +1069 -396
- package/package.json +14 -13
- package/runtime/scene-harness.json +1 -1
- 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-BvHobVhp.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-CfNCCBjN.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/api.ts +302 -0
- package/src/browserAuth.ts +87 -0
- package/src/cmgenTool.ts +104 -0
- package/src/commands/app.ts +896 -0
- package/src/commands/appAndroid.ts +603 -0
- package/src/commands/appDesktop.ts +339 -0
- package/src/commands/appDesktopMac.ts +470 -0
- package/src/commands/appIcon.ts +187 -0
- package/src/commands/appShared.ts +542 -0
- package/src/commands/appTemplates.ts +907 -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 +127 -0
- package/src/commands/dev.ts +236 -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 +130 -0
- package/src/commands/pull.ts +109 -0
- package/src/commands/push.ts +173 -0
- package/src/commands/render.ts +416 -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 +371 -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 +545 -0
- package/src/desktopScript.ts +276 -0
- package/src/dev/clientTemplates.ts +160 -0
- package/src/dev/devServer.ts +333 -0
- package/src/dev/webRunner.ts +22 -0
- package/src/dev/wsServer.ts +144 -0
- package/src/distRoot.ts +20 -0
- package/src/ignore.ts +163 -0
- package/src/index.ts +494 -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/compile/aspectMacro.ts +0 -86
- package/runtime/sdk/compile/assetIconMacro.ts +0 -384
- package/runtime/sdk/compile/assetMacro.ts +0 -146
- package/runtime/sdk/compile/assetName.ts +0 -50
- package/runtime/sdk/compile/bundler.ts +0 -287
- package/runtime/sdk/compile/compileProject.ts +0 -129
- package/runtime/sdk/compile/detectEntry.ts +0 -128
- package/runtime/sdk/compile/fontMacro.ts +0 -459
- package/runtime/sdk/compile/fontRegistry.ts +0 -78
- package/runtime/sdk/compile/header.ts +0 -67
- package/runtime/sdk/compile/index.ts +0 -100
- package/runtime/sdk/compile/libraryImports.ts +0 -52
- package/runtime/sdk/compile/liteMaterial.ts +0 -247
- package/runtime/sdk/compile/sceneEditor.ts +0 -88
- package/runtime/sdk/compile/serverSplit.ts +0 -233
- package/runtime/sdk/compile/serverTypes.ts +0 -227
- package/runtime/sdk/compile/sfnt.ts +0 -98
- package/runtime/sdk/compile/shaderSchema.ts +0 -202
- package/runtime/sdk/compile/shaderTargets.ts +0 -81
- package/runtime/sdk/compile/sourcemap.ts +0 -25
- package/runtime/sdk/core/Aspect.ts +0 -568
- package/runtime/sdk/core/InspectorUI.ts +0 -212
- package/runtime/sdk/core/StateMachine.ts +0 -308
- 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 -84
- 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 -107
- package/runtime/sdk/gl/Lightmap.ts +0 -256
- package/runtime/sdk/gl/Material.ts +0 -260
- package/runtime/sdk/gl/Mesh.ts +0 -83
- package/runtime/sdk/gl/Model.ts +0 -124
- package/runtime/sdk/gl/NavAgent.ts +0 -337
- package/runtime/sdk/gl/NavMesh.ts +0 -397
- 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/Ragdoll.ts +0 -270
- package/runtime/sdk/gl/Ray.ts +0 -16
- package/runtime/sdk/gl/Scene.ts +0 -486
- package/runtime/sdk/gl/Shape.ts +0 -409
- package/runtime/sdk/gl/Terrain.ts +0 -1092
- package/runtime/sdk/gl/Texture.ts +0 -63
- 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 -120
- package/runtime/sdk/gl/animation/Layer.ts +0 -31
- package/runtime/sdk/gl/animation/Loop.ts +0 -30
- package/runtime/sdk/gl/animation/Playback.ts +0 -44
- package/runtime/sdk/gl/animation/core.ts +0 -336
- 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/terrainMesh.ts +0 -219
- package/runtime/sdk/gl/touch.ts +0 -68
- package/runtime/sdk/inject.ts +0 -197
- 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/net/codec.ts +0 -119
- package/runtime/sdk/net/core.ts +0 -384
- package/runtime/sdk/net/index.ts +0 -181
- package/runtime/sdk/net/replication.ts +0 -622
- 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 -1353
- package/runtime/sdk/scene/editorPlugins.ts +0 -110
- 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 -830
- 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,178 @@
|
|
|
1
|
+
import { createRequire } from "node:module"
|
|
2
|
+
import { execFileSync } from "node:child_process"
|
|
3
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { extname, join, relative, resolve } from "node:path"
|
|
6
|
+
import { projectRootOf } from "./scene"
|
|
7
|
+
import { CliError, c, endProgress, flagBool, flagStr, log, note, progress, toKey, warnErr, type Args } from "../util"
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* `lecodes thumbs [path…]` — the scene editor's asset thumbnails, generated from the COMMAND LINE.
|
|
11
|
+
*
|
|
12
|
+
* The editor renders previews in a hidden browser host (3D assets need an engine), but image
|
|
13
|
+
* thumbnails need no engine at all — and a .ktx2 has no picture until something decodes it. This
|
|
14
|
+
* command fills the same cache the editor reads (`.lecodes/preview/<asset>.<hash>.png`, owned by
|
|
15
|
+
* lecodes-3d-editor's server/previewCache) with no browser in the loop, so opening the editor on a
|
|
16
|
+
* texture-heavy project shows a full grid immediately instead of rendering 40 thumbnails on the
|
|
17
|
+
* spot.
|
|
18
|
+
*
|
|
19
|
+
* Incremental by construction: the cache file name carries the first 10 hex of the SOURCE file's
|
|
20
|
+
* sha256, so an asset whose preview is already there is skipped after one hash — re-running over a
|
|
21
|
+
* big project is nearly free. `--force` ignores that.
|
|
22
|
+
*
|
|
23
|
+
* Decoders, in the order tried: sharp (through the optional `lecodes-assets` peer — handles webp),
|
|
24
|
+
* else jimp (bundled with this CLI: png / jpeg / bmp / gif / tiff). KTX2 goes through
|
|
25
|
+
* KTX-Software's `ktx extract` first (it is a GPU-compressed format; nothing in JS reads it), and
|
|
26
|
+
* the extracted PNG then takes the same resize path.
|
|
27
|
+
*
|
|
28
|
+
* NOT covered: models, materials and prefabs — those are RENDERED, which needs an engine. The
|
|
29
|
+
* editor still does them on demand (and `lecodes render --desktop` is the browserless renderer if
|
|
30
|
+
* that ever becomes a batch job).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const DEFAULT_SIZE = 256
|
|
34
|
+
const IMAGE_EXT = /\.(png|jpe?g|webp|bmp|gif|tiff?|ktx2)$/i
|
|
35
|
+
const RENDERED_EXT = /\.(glb|gltf|material\.ts|scene\.ts)$/i
|
|
36
|
+
|
|
37
|
+
/** KTX-Software's `ktx` CLI (v4.3+ has `extract`), resolved like lecodes-assets resolves toktx. */
|
|
38
|
+
const findKtx = (): string | null => {
|
|
39
|
+
const exe = process.platform === "win32" ? "ktx.exe" : "ktx"
|
|
40
|
+
const candidates = [
|
|
41
|
+
process.env.LECODES_KTX,
|
|
42
|
+
process.env.KTX_CLI,
|
|
43
|
+
process.env.KTX_TOKTX?.replace(/toktx(\.exe)?$/i, exe),
|
|
44
|
+
process.platform === "win32" ? join(process.env.LOCALAPPDATA ?? "", "Programs", "KTX-Software", "bin", exe) : null,
|
|
45
|
+
process.platform === "win32" ? join(process.env.ProgramFiles ?? "", "KTX-Software", "bin", exe) : null,
|
|
46
|
+
"/usr/local/bin/ktx",
|
|
47
|
+
"/opt/homebrew/bin/ktx",
|
|
48
|
+
].filter((p): p is string => !!p)
|
|
49
|
+
for (const path of candidates) { if (existsSync(path)) return path }
|
|
50
|
+
try {
|
|
51
|
+
execFileSync(exe, [ "--version" ], { stdio: "ignore" })
|
|
52
|
+
return exe
|
|
53
|
+
} catch { return null }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type Resizer = (file: string, size: number) => Promise<Uint8Array>
|
|
57
|
+
|
|
58
|
+
/** sharp through the `lecodes-assets` peer (it declares it optional), or null when neither is
|
|
59
|
+
* installed — jimp then covers everything but webp. */
|
|
60
|
+
const sharpResizer = (): Resizer | null => {
|
|
61
|
+
try {
|
|
62
|
+
const req = createRequire(import.meta.url)
|
|
63
|
+
const sharp = createRequire(req.resolve("lecodes-assets/cli"))("sharp")
|
|
64
|
+
return async (file, size) =>
|
|
65
|
+
sharp(file).resize({ width: size, height: size, fit: "inside", withoutEnlargement: true }).png().toBuffer()
|
|
66
|
+
} catch { return null }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const jimpResizer = (): Resizer => async (file, size) => {
|
|
70
|
+
const { Jimp } = await import("jimp")
|
|
71
|
+
const img = await Jimp.read(file)
|
|
72
|
+
if (Math.max(img.width, img.height) > size) img.scaleToFit({ w: size, h: size })
|
|
73
|
+
return await img.getBuffer("image/png")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** KTX2 → a temporary PNG (level 0, transcoded to RGBA8 when the payload is Basis-compressed). */
|
|
77
|
+
const ktxToPng = (ktx: string, file: string, out: string): void => {
|
|
78
|
+
const run = (args: string[]) => execFileSync(ktx, args, { stdio: [ "ignore", "ignore", "pipe" ], encoding: "utf8" })
|
|
79
|
+
try {
|
|
80
|
+
run([ "extract", "--transcode", "rgba8", "--level", "0", file, out ])
|
|
81
|
+
} catch (e: any) {
|
|
82
|
+
// Only Basis (ETC1S/UASTC) payloads are transcodable; a plain uncompressed KTX2 extracts as-is
|
|
83
|
+
run([ "extract", "--level", "0", file, out ])
|
|
84
|
+
if (!existsSync(out)) throw e
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Every previewable file under a folder — the SAME sweep the editor's asset panel lists with
|
|
89
|
+
* (node_modules + dotfiles out, nothing else): what this command doesn't cover, the browser would
|
|
90
|
+
* end up rendering itself. */
|
|
91
|
+
const collect = (dir: string): string[] => {
|
|
92
|
+
const out: string[] = []
|
|
93
|
+
for (const name of readdirSync(dir)) {
|
|
94
|
+
if (name === "node_modules" || name.startsWith(".")) continue
|
|
95
|
+
const full = join(dir, name)
|
|
96
|
+
if (statSync(full).isDirectory()) out.push(...collect(full))
|
|
97
|
+
else if (IMAGE_EXT.test(name)) out.push(full)
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const thumbs = async (args: Args) => {
|
|
103
|
+
const root = projectRootOf(process.cwd())
|
|
104
|
+
|
|
105
|
+
let sceneServer: typeof import("lecodes-3d-editor/server")
|
|
106
|
+
try {
|
|
107
|
+
sceneServer = await import("lecodes-3d-editor/server")
|
|
108
|
+
} catch {
|
|
109
|
+
throw new CliError("The scene editor isn't installed (it owns the preview cache). Run: npm install -g lecodes-3d-editor")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const size = Number(flagStr(args, "size")) || DEFAULT_SIZE
|
|
113
|
+
const force = flagBool(args, "force")
|
|
114
|
+
|
|
115
|
+
// Targets: the paths given (a file or a folder each), else the whole project.
|
|
116
|
+
let files: string[] = []
|
|
117
|
+
const rendered: string[] = []
|
|
118
|
+
if (args._.length > 0) {
|
|
119
|
+
for (const arg of args._) {
|
|
120
|
+
const abs = resolve(process.cwd(), arg)
|
|
121
|
+
if (!existsSync(abs)) throw new CliError(`No such file or folder: ${arg}`)
|
|
122
|
+
if (statSync(abs).isDirectory()) files.push(...collect(abs))
|
|
123
|
+
else if (IMAGE_EXT.test(abs)) files.push(abs)
|
|
124
|
+
else if (RENDERED_EXT.test(abs)) rendered.push(arg)
|
|
125
|
+
else throw new CliError(`Not a previewable asset: ${arg}`)
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
files = collect(root)
|
|
129
|
+
}
|
|
130
|
+
files = [ ...new Set(files) ]
|
|
131
|
+
|
|
132
|
+
if (files.length === 0 && rendered.length === 0) {
|
|
133
|
+
log("No images to thumbnail.")
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ktx = files.some((f) => f.toLowerCase().endsWith(".ktx2")) ? findKtx() : null
|
|
138
|
+
const sharp = sharpResizer()
|
|
139
|
+
const resize = sharp ?? jimpResizer()
|
|
140
|
+
const tmp = mkdtempSync(join(tmpdir(), "lecodes-thumbs-"))
|
|
141
|
+
|
|
142
|
+
let made = 0, fresh = 0
|
|
143
|
+
const skipped: string[] = []
|
|
144
|
+
try {
|
|
145
|
+
for (const [ i, file ] of files.entries()) {
|
|
146
|
+
const asset = "./" + toKey(relative(root, file))
|
|
147
|
+
progress(i, files.length, asset)
|
|
148
|
+
const hash = sceneServer.hashAsset(root, asset)
|
|
149
|
+
if (!hash) continue
|
|
150
|
+
if (!force && sceneServer.previewIsCurrent(root, asset, hash)) { fresh++; continue }
|
|
151
|
+
|
|
152
|
+
const ext = extname(file).toLowerCase()
|
|
153
|
+
if (ext === ".webp" && !sharp) { skipped.push(`${asset} (webp needs sharp — npm install -g lecodes-assets)`); continue }
|
|
154
|
+
try {
|
|
155
|
+
let source = file
|
|
156
|
+
if (ext === ".ktx2") {
|
|
157
|
+
if (!ktx) { skipped.push(`${asset} (KTX2 needs KTX-Software's \`ktx\` — set LECODES_KTX or put it on PATH)`); continue }
|
|
158
|
+
source = join(tmp, `${i}.png`)
|
|
159
|
+
ktxToPng(ktx, file, source)
|
|
160
|
+
}
|
|
161
|
+
sceneServer.writePreviewFile(root, asset, await resize(source, size))
|
|
162
|
+
made++
|
|
163
|
+
} catch (e: any) {
|
|
164
|
+
skipped.push(`${asset} (${(e?.message ?? String(e)).split("\n")[0]})`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
endProgress()
|
|
169
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
log(`${c.bold(String(made))} generated · ${fresh} already current${skipped.length > 0 ? ` · ${skipped.length} skipped` : ""}`)
|
|
173
|
+
for (const s of skipped) warnErr(s)
|
|
174
|
+
if (rendered.length > 0) {
|
|
175
|
+
warnErr(`Models, materials and prefabs are RENDERED, not decoded — the scene editor does those: ${rendered.join(", ")}`)
|
|
176
|
+
}
|
|
177
|
+
note(`Cache: ${join(root, ".lecodes", "preview")} (re-runs skip everything unchanged)`)
|
|
178
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { resolve } from "node:path"
|
|
2
|
+
import { getTypes } from "../api"
|
|
3
|
+
import { DEFAULT_API, loadConfig, normalizeApiUrl } from "../config"
|
|
4
|
+
import { findProjectRoot, LECODES_DIR } from "../manifest"
|
|
5
|
+
import {
|
|
6
|
+
bundleHash, readLocalTypeBundle, readProjectTypeBundle, writeTsconfig, writeTypeBundle,
|
|
7
|
+
type TypeBundle,
|
|
8
|
+
} from "../types"
|
|
9
|
+
import { CliError, c, flagBool, flagStr, log, note, success, warn, type Args } from "../util"
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* The types step of `lecodes update` (formerly `lecodes types [dir]`) — re-materialize the IDE type surface (<root>/.lecodes/types/ + tsconfig).
|
|
13
|
+
*
|
|
14
|
+
* Every project gets these types once, at scaffold time: `clone`/`create`/`pull` fetch them from
|
|
15
|
+
* GET /api/types (the deployed runtime's surface), while `init` and `design` — which run with no
|
|
16
|
+
* server and no login — write the bundle vendored into the CLI at publish time. Neither source
|
|
17
|
+
* refreshes on its own, so a local project's types age with the CLI binary that made it. This
|
|
18
|
+
* command is the refresh: server by default (newest SDK the platform actually runs), `--local` for
|
|
19
|
+
* the CLI's own copy, `--check` to only report drift (non-zero exit when stale).
|
|
20
|
+
*
|
|
21
|
+
* IDE-only: the compiler reads the SDK sources vendored in runtime/sdk, never .lecodes/types. When
|
|
22
|
+
* the two sources disagree the command says so — types promising APIs this CLI can't compile is
|
|
23
|
+
* exactly the confusing case worth naming out loud.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const label = (bundle: TypeBundle) => `${Object.keys(bundle).length} file(s)`
|
|
27
|
+
|
|
28
|
+
export const types = async (args: Args) => {
|
|
29
|
+
const dirArg = args._[0]
|
|
30
|
+
const root = dirArg ? resolve(dirArg) : (() => {
|
|
31
|
+
try { return findProjectRoot(process.cwd()) } catch { return process.cwd() }
|
|
32
|
+
})()
|
|
33
|
+
|
|
34
|
+
const useLocal = flagBool(args, "local")
|
|
35
|
+
const checkOnly = flagBool(args, "check")
|
|
36
|
+
const local = readLocalTypeBundle()
|
|
37
|
+
|
|
38
|
+
// ---- pick the source ----
|
|
39
|
+
let bundle: TypeBundle | null = null
|
|
40
|
+
let source = ""
|
|
41
|
+
if (useLocal) {
|
|
42
|
+
if (!local) throw new CliError("This CLI ships no vendored type bundle — drop --local to fetch them from the server.")
|
|
43
|
+
bundle = local
|
|
44
|
+
source = "the CLI's bundled SDK"
|
|
45
|
+
} else {
|
|
46
|
+
const apiUrl = normalizeApiUrl(flagStr(args, "api") ?? loadConfig().apiUrl ?? DEFAULT_API)
|
|
47
|
+
try {
|
|
48
|
+
bundle = await getTypes(apiUrl)
|
|
49
|
+
source = apiUrl
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (!local || checkOnly) throw e
|
|
52
|
+
warn(`${(e as Error).message}`)
|
|
53
|
+
bundle = local
|
|
54
|
+
source = "the CLI's bundled SDK (server unreachable)"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
log(`${c.green("source")} ${source} — ${label(bundle)}`)
|
|
58
|
+
|
|
59
|
+
// ---- report drift, write unless --check ----
|
|
60
|
+
const onDisk = readProjectTypeBundle(root)
|
|
61
|
+
const same = bundleHash(onDisk) === bundleHash(bundle)
|
|
62
|
+
if (checkOnly) {
|
|
63
|
+
if (Object.keys(onDisk).length === 0) throw new CliError(`No types in ${LECODES_DIR}/types/ — run \`lecodes update\`.`)
|
|
64
|
+
if (!same) throw new CliError(`Types are out of date (${label(onDisk)} on disk) — run \`lecodes update\`.`)
|
|
65
|
+
success(`Types are up to date (${label(onDisk)}).`)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const stats = writeTypeBundle(root, bundle)
|
|
70
|
+
writeTsconfig(root)
|
|
71
|
+
const changes = [
|
|
72
|
+
stats.added ? `${stats.added} added` : null,
|
|
73
|
+
stats.updated ? `${stats.updated} updated` : null,
|
|
74
|
+
stats.removed ? `${stats.removed} removed` : null,
|
|
75
|
+
].filter(Boolean)
|
|
76
|
+
log(`${c.green("written")} ${LECODES_DIR}/types/ + tsconfig.json ${c.dim(changes.length ? `(${changes.join(", ")})` : "(already current)")}`)
|
|
77
|
+
|
|
78
|
+
// The compiler uses the SDK vendored in this CLI, not these declarations — worth saying when the
|
|
79
|
+
// server's surface is a different one, since that gap shows up as "autocompletes but won't build".
|
|
80
|
+
if (!useLocal && local && bundleHash(local) !== bundleHash(bundle)) {
|
|
81
|
+
warn("The server's SDK surface differs from the one this CLI compiles with — if something autocompletes but fails to compile, update the CLI (`npm install -g lecodes-cli@latest`) or re-run with --local.")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
log("")
|
|
85
|
+
success(stats.added + stats.updated + stats.removed > 0 ? "Types synced." : "Types already current.")
|
|
86
|
+
if (!dirArg) note("Reload the TypeScript server in your editor to pick them up.")
|
|
87
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import { dirname, join, resolve } from "node:path"
|
|
5
|
+
import pkg from "../../package.json"
|
|
6
|
+
import { CliError, c, flagBool, log, note, type Args } from "../util"
|
|
7
|
+
import { OPTIONAL_PEERS, peerVersion } from "../peers"
|
|
8
|
+
import { updateDesktop } from "../desktopRenderer"
|
|
9
|
+
import { updateMatc } from "../matcTool"
|
|
10
|
+
import { types } from "./types"
|
|
11
|
+
import { shaders } from "./shaders"
|
|
12
|
+
import { app } from "./app"
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* `lecodes update` — one command that moves EVERYTHING a project depends on to its newest published
|
|
16
|
+
* state, in the order the pieces depend on each other:
|
|
17
|
+
*
|
|
18
|
+
* 1. desktop the LeCodes Desktop host `desktop run` / `render --desktop` use (= `desktop update`)
|
|
19
|
+
* 2. matc Filament's material compiler for local shader compiles (= `shaders update`)
|
|
20
|
+
* 3. shaders recompile the project's .mat files with that matc (= `shaders compile`)
|
|
21
|
+
* 4. types the IDE types (.lecodes/types + tsconfig) from the server (= `types`)
|
|
22
|
+
* 5. shells ios/ + android/ pins → newest SDK release + plugin tags, re-sync (= `app update`)
|
|
23
|
+
* 6. cli the GLOBAL npm installs — every installed optional peer (lecodes-renderer,
|
|
24
|
+
* lecodes-design, lecodes-3d-editor, lecodes-assets) and the CLI itself that is behind
|
|
25
|
+
* npm's latest: `npm install -g …@latest` (peers first, the CLI last; the standalone
|
|
26
|
+
* binary can't come from npm — it says so and does the peers only)
|
|
27
|
+
*
|
|
28
|
+
* Every step is the existing subcommand, so nothing here can drift from what the individual
|
|
29
|
+
* commands do; this only sequences them and keeps going when one fails (the summary says which).
|
|
30
|
+
* Steps that the environment pins are SKIPPED, not overridden: LECODES_DESKTOP_EXE means a local
|
|
31
|
+
* host build (engine work — the cache is irrelevant), LECODES_MATC a local matc; the project steps
|
|
32
|
+
* need a project (a dir with app.json / .lecodes above the cwd) and the shell step a shell folder.
|
|
33
|
+
*
|
|
34
|
+
* Flags: --local (types from the SDK bundled in this CLI instead of the server), --no-shells (leave
|
|
35
|
+
* ios/android pins alone — `app update` re-syncs, which compiles the bundle and, for server projects,
|
|
36
|
+
* needs a login), --check (the npm step only REPORTS what is behind and prints the install line),
|
|
37
|
+
* --no-cli (skip the npm step — offline / air-gapped machines).
|
|
38
|
+
*
|
|
39
|
+
* `lecodes types` used to be its own command; the types step here replaced it (2026-09-04).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The nearest project at or above `start`: a dir with app.json, or a .lecodes/ that holds a project's
|
|
43
|
+
* manifest or types. NOT the bare `.lecodes/` marker projectEnv uses — the CLI's own config lives in
|
|
44
|
+
* ~/.lecodes, so that rule would turn any folder under the home dir into a "project" and the types
|
|
45
|
+
* step would scaffold .lecodes/types + tsconfig.json into it. */
|
|
46
|
+
const findProjectDir = (start: string): string | null => {
|
|
47
|
+
const home = resolve(homedir())
|
|
48
|
+
let dir = resolve(start)
|
|
49
|
+
while (true) {
|
|
50
|
+
if (dir !== home && (existsSync(join(dir, "app.json")) || existsSync(join(dir, ".lecodes", "manifest.json")) || existsSync(join(dir, ".lecodes", "types")))) return dir
|
|
51
|
+
const parent = dirname(dir)
|
|
52
|
+
if (parent === dir) return null
|
|
53
|
+
dir = parent
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Outcome = "updated" | "ok" | "skipped" | "failed"
|
|
58
|
+
type Step = { name: string, outcome: Outcome, detail?: string }
|
|
59
|
+
|
|
60
|
+
const NPM = "https://registry.npmjs.org"
|
|
61
|
+
|
|
62
|
+
/** `latest` dist-tag of an npm package, or null when npm is unreachable / the package is unknown. */
|
|
63
|
+
const npmLatest = async (name: string): Promise<string | null> => {
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(`${NPM}/${name}/latest`, { signal: AbortSignal.timeout(4000) })
|
|
66
|
+
if (!res.ok) return null
|
|
67
|
+
const json = await res.json() as { version?: string }
|
|
68
|
+
return json.version ?? null
|
|
69
|
+
} catch {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const semverNewer = (a: string, b: string): number => {
|
|
75
|
+
const pa = a.split(/[.-]/).map(Number), pb = b.split(/[.-]/).map(Number)
|
|
76
|
+
for (let i = 0; i < 3; i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d }
|
|
77
|
+
return 0
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Running straight from the repo checkout (src/commands/update.ts), not a built dist/binary. */
|
|
81
|
+
const FROM_SOURCE = /[\\/]src[\\/]commands[\\/]/.test(import.meta.url)
|
|
82
|
+
/** The standalone release build: the bundle + chisel sit next to the executable (same test as index.ts). */
|
|
83
|
+
const STANDALONE = existsSync(join(dirname(process.execPath), "runtime")) &&
|
|
84
|
+
existsSync(join(dirname(process.execPath), process.platform === "win32" ? "chisel.exe" : "chisel"))
|
|
85
|
+
|
|
86
|
+
export const update = async (args: Args) => {
|
|
87
|
+
const steps: Step[] = []
|
|
88
|
+
const heading = (name: string, what: string) => log(`\n${c.bold(name)} ${c.dim(what)}`)
|
|
89
|
+
const run = async (name: string, what: string, fn: () => Promise<Outcome | [Outcome, string]>) => {
|
|
90
|
+
heading(name, what)
|
|
91
|
+
try {
|
|
92
|
+
const r = await fn()
|
|
93
|
+
const [outcome, detail] = Array.isArray(r) ? r : [r, undefined]
|
|
94
|
+
steps.push({ name, outcome, detail })
|
|
95
|
+
if (outcome === "skipped" && detail) note(`skipped — ${detail}`)
|
|
96
|
+
} catch (e) {
|
|
97
|
+
const msg = e instanceof CliError ? e.message : e instanceof Error ? e.message : String(e)
|
|
98
|
+
log(c.red("✗ ") + msg)
|
|
99
|
+
steps.push({ name, outcome: "failed", detail: msg })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const root = findProjectDir(process.cwd())
|
|
104
|
+
|
|
105
|
+
// 1. desktop host
|
|
106
|
+
await run("desktop", "LeCodes Desktop host (desktop run / render --desktop)", async () => {
|
|
107
|
+
if (process.env.LECODES_DESKTOP_EXE) return ["skipped", `LECODES_DESKTOP_EXE=${process.env.LECODES_DESKTOP_EXE} (a local build; \`lecodes desktop update\` refreshes the cache anyway)`]
|
|
108
|
+
await updateDesktop()
|
|
109
|
+
return "ok"
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// 2. matc
|
|
113
|
+
await run("matc", "Filament material compiler for local shader compiles", async () => {
|
|
114
|
+
if (process.env.LECODES_MATC) return ["skipped", `LECODES_MATC=${process.env.LECODES_MATC} (a local matc)`]
|
|
115
|
+
await updateMatc()
|
|
116
|
+
return "ok"
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// 3. shaders (project)
|
|
120
|
+
await run("shaders", "recompile the project's .mat shaders with the current matc", async () => {
|
|
121
|
+
if (!root) return ["skipped", "not inside a project"]
|
|
122
|
+
await shaders({ _: ["compile"], flags: {} })
|
|
123
|
+
return "ok"
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// 4. types (project)
|
|
127
|
+
await run("types", flagBool(args, "local") ? "IDE types from the SDK bundled in this CLI" : "IDE types from the server", async () => {
|
|
128
|
+
if (!root) return ["skipped", "not inside a project"]
|
|
129
|
+
await types({ _: [], flags: flagBool(args, "local") ? { local: true } : {} })
|
|
130
|
+
return "ok"
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// 5. native shells (project)
|
|
134
|
+
await run("shells", "ios/ + android/ pins → newest SDK release + plugin tags, then re-sync", async () => {
|
|
135
|
+
if (!root) return ["skipped", "not inside a project"]
|
|
136
|
+
if (flagBool(args, "no-shells")) return ["skipped", "--no-shells"]
|
|
137
|
+
const present = ["ios", "android"].filter((d) => existsSync(join(root!, d)) && readdirSync(join(root!, d)).length > 0)
|
|
138
|
+
if (present.length === 0) return ["skipped", "no ios/ or android/ shell in this project (`lecodes app init ios|android`)"]
|
|
139
|
+
await app({ _: ["update"], flags: {} })
|
|
140
|
+
return "ok"
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// 6. this CLI + its optional peers — the GLOBAL npm installs (`npm install -g`, how the CLI and its
|
|
144
|
+
// peers are installed): every installed one that is behind npm's latest moves forward. The peers go
|
|
145
|
+
// first, the CLI itself last — replacing the running CLI's files is fine (this process already
|
|
146
|
+
// holds its code), but its new version is what the next invocation runs, so nothing after it here.
|
|
147
|
+
// Peers that are NOT installed stay that way: they're optional, installing one is a choice.
|
|
148
|
+
await run("cli", "lecodes-cli + its optional peers (global npm installs)", async () => {
|
|
149
|
+
if (flagBool(args, "no-cli")) return ["skipped", "--no-cli"]
|
|
150
|
+
if (FROM_SOURCE) return ["skipped", "running from the repo checkout — the CLI and its peers are the repo's own"]
|
|
151
|
+
const rows: { name: string, installed: string | null, latest: string | null }[] = []
|
|
152
|
+
for (const [name, probe] of OPTIONAL_PEERS) {
|
|
153
|
+
const installed = peerVersion(name, probe)
|
|
154
|
+
rows.push({ name, installed, latest: installed ? await npmLatest(name) : null })
|
|
155
|
+
}
|
|
156
|
+
rows.push({ name: "lecodes-cli", installed: pkg.version, latest: await npmLatest("lecodes-cli") })
|
|
157
|
+
if (rows.every((r) => r.latest === null)) return ["skipped", "npm unreachable"]
|
|
158
|
+
const behind = rows.filter((r) => r.installed && r.latest && semverNewer(r.latest, r.installed) > 0)
|
|
159
|
+
for (const r of rows) {
|
|
160
|
+
const state = !r.installed ? c.dim("not installed (optional — npm install -g " + r.name + ")")
|
|
161
|
+
: !r.latest ? c.dim("(npm: unknown)")
|
|
162
|
+
: behind.includes(r) ? c.yellow("→ " + r.latest)
|
|
163
|
+
: c.dim("(up to date)")
|
|
164
|
+
log(" " + r.name.padEnd(18) + " " + (r.installed ?? "").padEnd(8) + " " + state)
|
|
165
|
+
}
|
|
166
|
+
if (behind.length === 0) return "ok"
|
|
167
|
+
const specs = behind.map((r) => r.name + "@latest")
|
|
168
|
+
if (flagBool(args, "check")) return ["updated", behind.length + " behind — run: npm install -g " + specs.join(" ")]
|
|
169
|
+
if (STANDALONE && behind.some((r) => r.name === "lecodes-cli")) {
|
|
170
|
+
note("this is the standalone build — the CLI updates by re-installing the release archive, not through npm; peers still go through npm")
|
|
171
|
+
}
|
|
172
|
+
const toInstall = STANDALONE ? specs.filter((s) => !s.startsWith("lecodes-cli@")) : specs
|
|
173
|
+
if (toInstall.length === 0) return ["skipped", "only the standalone CLI itself is behind — download the release"]
|
|
174
|
+
log(" " + c.dim("$") + " npm install -g " + toInstall.join(" "))
|
|
175
|
+
const r = spawnSync("npm", ["install", "-g", ...toInstall], { stdio: "inherit", shell: true })
|
|
176
|
+
if (r.status !== 0) throw new CliError("npm install -g exited with " + (r.status ?? "a signal"))
|
|
177
|
+
const names = toInstall.map((s) => s.replace("@latest", "")).join(", ")
|
|
178
|
+
const cliToo = toInstall.some((s) => s.startsWith("lecodes-cli@"))
|
|
179
|
+
return ["updated", names + " → latest" + (cliToo ? " (the new CLI runs from your next command)" : "")]
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Summary — one line per step so a scrolled-away failure can't hide.
|
|
183
|
+
log(`\n${c.bold("Summary")}`)
|
|
184
|
+
const width = Math.max(...steps.map((s) => s.name.length))
|
|
185
|
+
for (const s of steps) {
|
|
186
|
+
const mark = s.outcome === "failed" ? c.red("✗") : s.outcome === "skipped" ? c.dim("–") : c.green("✓")
|
|
187
|
+
log(` ${mark} ${s.name.padEnd(width)} ${s.outcome === "failed" ? c.red(s.detail ?? "failed") : s.outcome === "skipped" ? c.dim(s.detail ?? "skipped") : s.detail ?? s.outcome}`)
|
|
188
|
+
}
|
|
189
|
+
if (steps.some((s) => s.outcome === "failed")) process.exitCode = 1
|
|
190
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { applyAssetIconMacro, buildIconsHeaderValue, collectVendoredIcons, iconNotFoundError, ICON_ID, type AssetIconEntry } from "lecodes-sdk/compile"
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The CLI side of the `assetIcon('pack:name')` compile macro. The scan/rewrite core lives in the
|
|
9
|
+
* SDK (sdk/compile assetIconMacro — shared with the backend); this wrapper decides where the SVG
|
|
10
|
+
* bodies come from per compile mode:
|
|
11
|
+
*
|
|
12
|
+
* design — `lecodes design` screens: icons vendor INTO the design folder
|
|
13
|
+
* (assets/icons/<pack>/<name>.svg + icons.lock.json + ICON-NOTICES.md), so the pushed
|
|
14
|
+
* design renders offline (the platform board resolves vendored svgs first, its own
|
|
15
|
+
* cache/registry for the rest) and attribution ships in the user's repo.
|
|
16
|
+
* app — `lecodes compile` / `dev` / `render` / `test`: icons resolve from the user-level cache
|
|
17
|
+
* (~/.lecodes/cache/icons/<pack>/<name>.svg), fetching misses from the registry. Nothing
|
|
18
|
+
* is written into the project — the project tree stays exactly what the user wrote.
|
|
19
|
+
* No lock: registry `/v1` ids are immutable (a redesigned glyph is a new name or a /v2),
|
|
20
|
+
* so a cached SVG is valid forever. Attribution rides the bundle's `// icons:` header
|
|
21
|
+
* (packs inlined) + the platform's attribution surface, not an in-repo notice.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Default registry base — per-icon SVGs at `<base>/<pack>/<name>.svg`, licenses at `<base>/licenses.json`.
|
|
25
|
+
* A local directory (with the same layout) works too, for tests. Override with LECODES_ICON_REGISTRY. */
|
|
26
|
+
const DEFAULT_REGISTRY = process.env.LECODES_ICON_REGISTRY?.trim() || "https://icon-registry.jt3.ru/v1"
|
|
27
|
+
|
|
28
|
+
// ---- registry access ----------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
type LicenseEntry = { spdx: string, title: string, url: string, attribution: string | null, modifiedNotice: boolean }
|
|
31
|
+
|
|
32
|
+
const isHttp = (s: string) => /^https?:/i.test(s)
|
|
33
|
+
const sha12 = (s: string) => createHash("sha256").update(s).digest("hex").slice(0, 12)
|
|
34
|
+
|
|
35
|
+
const readText = async (registry: string, rel: string): Promise<string | null> => {
|
|
36
|
+
if (isHttp(registry)) {
|
|
37
|
+
// Network failure reads as absent — callers turn a fully-unreachable registry into a clear error.
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(`${registry}/${rel}`)
|
|
40
|
+
return res.ok ? await res.text() : null
|
|
41
|
+
} catch { return null }
|
|
42
|
+
}
|
|
43
|
+
try { return readFileSync(join(registry, rel), "utf8") } catch { return null }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const safeRead = (path: string): string | null => { try { return readFileSync(path, "utf8") } catch { return null } }
|
|
47
|
+
|
|
48
|
+
const writeIfChanged = (path: string, text: string): boolean => {
|
|
49
|
+
const current = existsSync(path) ? safeRead(path) : null
|
|
50
|
+
if (current === text) return false
|
|
51
|
+
writeFileSync(path, text)
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Fetch one icon's SVG; a miss throws the legible SDK-built error ("did you mean …" from the
|
|
56
|
+
* pack's meta.json, or the available-pack list when the pack itself is unknown). */
|
|
57
|
+
const fetchIcon = async (registry: string, pack: string, name: string): Promise<string> => {
|
|
58
|
+
const svg = await readText(registry, `${pack}/${name}.svg`)
|
|
59
|
+
if (svg !== null) return svg
|
|
60
|
+
throw await iconNotFoundError(pack, name, (rel) => readText(registry, rel), registry)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const loadLicenses = async (registry: string): Promise<Record<string, LicenseEntry>> => {
|
|
64
|
+
const raw = await readText(registry, "licenses.json")
|
|
65
|
+
if (!raw) throw new Error(`assetIcon: can't reach the icon registry (${registry}). Check your connection, or set LECODES_ICON_REGISTRY.`)
|
|
66
|
+
return JSON.parse(raw) as Record<string, LicenseEntry>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- design mode: vendoring (registry → design/assets/icons + lock + notices) --------------------
|
|
70
|
+
|
|
71
|
+
type LockIcon = { pack: string, name: string, hash: string, spdx: string, path: string }
|
|
72
|
+
type Lock = { registry: string, icons: Record<string, LockIcon>, packs: Record<string, LicenseEntry> }
|
|
73
|
+
|
|
74
|
+
const readLock = (path: string): Lock | null => {
|
|
75
|
+
try {
|
|
76
|
+
const lock = JSON.parse(readFileSync(path, "utf8")) as Lock
|
|
77
|
+
if (lock && typeof lock === "object" && lock.icons && lock.packs) return lock
|
|
78
|
+
} catch { /* absent or corrupt → start fresh */ }
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A stable serialization: keys sorted, no timestamp — same inputs produce byte-identical output. */
|
|
83
|
+
const serializeLock = (lock: Lock): string => {
|
|
84
|
+
const sortKeys = <T>(rec: Record<string, T>): Record<string, T> =>
|
|
85
|
+
Object.fromEntries(Object.keys(rec).sort().map((k) => [k, rec[k]]))
|
|
86
|
+
return JSON.stringify({ registry: lock.registry, icons: sortKeys(lock.icons), packs: sortKeys(lock.packs) }, null, 2) + "\n"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const renderNotices = (lock: Lock): string => {
|
|
90
|
+
const packs = [...new Set(Object.values(lock.icons).map((i) => i.pack))].sort()
|
|
91
|
+
const lines = [
|
|
92
|
+
"# Icon attribution & notices",
|
|
93
|
+
"",
|
|
94
|
+
"Icons vendored from the icon-registry via `assetIcon(...)`. Keep this file in your repo / product.",
|
|
95
|
+
"",
|
|
96
|
+
]
|
|
97
|
+
for (const p of packs) {
|
|
98
|
+
const l = lock.packs[p]
|
|
99
|
+
if (!l) continue
|
|
100
|
+
let note = `- **${p}** — ${l.spdx}${l.url ? ` — ${l.url}` : ""}`
|
|
101
|
+
if (l.attribution) note += `\n - **Attribution required:** ${l.attribution}`
|
|
102
|
+
if (l.modifiedNotice) note += `\n - ${l.spdx}: state any modifications if you alter the glyphs.`
|
|
103
|
+
lines.push(note)
|
|
104
|
+
}
|
|
105
|
+
lines.push("")
|
|
106
|
+
return lines.join("\n")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Resolve `ids` to their SVG bodies, vendoring any not already on disk. Reads cached icons off disk
|
|
110
|
+
* (no network) and only fetches the misses; then keeps icons.lock.json + ICON-NOTICES.md in sync.
|
|
111
|
+
* Returns id → svg. Throws a clear compile error on an unknown pack / missing icon. */
|
|
112
|
+
export const vendorIcons = async (
|
|
113
|
+
designDir: string,
|
|
114
|
+
ids: string[],
|
|
115
|
+
opts: { registry?: string } = {},
|
|
116
|
+
): Promise<Map<string, string>> => {
|
|
117
|
+
const registry = (opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "")
|
|
118
|
+
const lockPath = join(designDir, "icons.lock.json")
|
|
119
|
+
const lock: Lock = readLock(lockPath) ?? { registry, icons: {}, packs: {} }
|
|
120
|
+
const svgById = new Map<string, string>()
|
|
121
|
+
|
|
122
|
+
// Serve from the on-disk vendor cache where we can; collect the rest.
|
|
123
|
+
const missing: string[] = []
|
|
124
|
+
for (const id of ids) {
|
|
125
|
+
const li = lock.icons[id]
|
|
126
|
+
const cached = li && safeRead(join(designDir, li.path))
|
|
127
|
+
if (cached) svgById.set(id, cached)
|
|
128
|
+
else missing.push(id)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (missing.length) {
|
|
132
|
+
const licenses = await loadLicenses(registry)
|
|
133
|
+
|
|
134
|
+
for (const id of missing) {
|
|
135
|
+
const m = ICON_ID.exec(id)! // scanner validated the shape already
|
|
136
|
+
const [, pack, name] = m
|
|
137
|
+
const lic = licenses[pack]
|
|
138
|
+
if (!lic) throw new Error(`assetIcon: unknown icon pack "${pack}" (in "${id}"). Available packs: ${Object.keys(licenses).sort().join(", ")}.`)
|
|
139
|
+
const svg = await fetchIcon(registry, pack, name)
|
|
140
|
+
|
|
141
|
+
const rel = `assets/icons/${pack}/${name}.svg`
|
|
142
|
+
const abs = join(designDir, rel)
|
|
143
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
144
|
+
writeFileSync(abs, svg)
|
|
145
|
+
lock.icons[id] = { pack, name, hash: sha12(svg), spdx: lic.spdx, path: rel }
|
|
146
|
+
lock.packs[pack] = { spdx: lic.spdx, title: lic.title, url: lic.url, attribution: lic.attribution, modifiedNotice: lic.modifiedNotice }
|
|
147
|
+
svgById.set(id, svg)
|
|
148
|
+
}
|
|
149
|
+
lock.registry = registry
|
|
150
|
+
writeIfChanged(lockPath, serializeLock(lock))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Attribution is fail-closed: notices always reflect the vendored packs (write throws → build fails).
|
|
154
|
+
if (Object.keys(lock.icons).length) writeIfChanged(join(designDir, "ICON-NOTICES.md"), renderNotices(lock))
|
|
155
|
+
|
|
156
|
+
return svgById
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The design-screen compile pass: resolve + vendor every `assetIcon(...)` into the design folder
|
|
160
|
+
* and rewrite the calls inline (mutates `entries[].text`). A no-op when nothing uses `assetIcon`. */
|
|
161
|
+
export const applyAssetIcons = async (
|
|
162
|
+
entries: AssetIconEntry[],
|
|
163
|
+
designDir: string,
|
|
164
|
+
opts: { registry?: string } = {},
|
|
165
|
+
): Promise<void> => {
|
|
166
|
+
await applyAssetIconMacro(entries, (ids) => vendorIcons(designDir, ids, opts))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- app mode: the user-level icon cache ----------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/** `~/.lecodes/cache/icons` — shared by every project on the machine (icons are project-independent
|
|
172
|
+
* and immutable per id, so one fetch ever). Sits next to the CLI's other user-level state (config.ts). */
|
|
173
|
+
const defaultCacheDir = () => join(homedir(), ".lecodes", "cache", "icons")
|
|
174
|
+
|
|
175
|
+
/** Resolve `ids` via the user-level cache, fetching misses from the registry. No lock, no notices,
|
|
176
|
+
* no project writes (see the header comment). Returns id → svg. */
|
|
177
|
+
export const cacheIcons = async (
|
|
178
|
+
ids: string[],
|
|
179
|
+
opts: { registry?: string, cacheDir?: string } = {},
|
|
180
|
+
): Promise<Map<string, string>> => {
|
|
181
|
+
const registry = (opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "")
|
|
182
|
+
const cacheDir = opts.cacheDir ?? defaultCacheDir()
|
|
183
|
+
const svgById = new Map<string, string>()
|
|
184
|
+
|
|
185
|
+
for (const id of ids) {
|
|
186
|
+
const [, pack, name] = ICON_ID.exec(id)! // scanner validated the shape already
|
|
187
|
+
const cachePath = join(cacheDir, pack, `${name}.svg`)
|
|
188
|
+
const cached = safeRead(cachePath)
|
|
189
|
+
if (cached) { svgById.set(id, cached); continue }
|
|
190
|
+
|
|
191
|
+
const svg = await fetchIcon(registry, pack, name)
|
|
192
|
+
mkdirSync(dirname(cachePath), { recursive: true })
|
|
193
|
+
writeFileSync(cachePath, svg)
|
|
194
|
+
svgById.set(id, svg)
|
|
195
|
+
}
|
|
196
|
+
return svgById
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The app-compile pass (`compile`/`dev`/`render`/`test`): resolve every `assetIcon(...)` — icons
|
|
200
|
+
* the design flow already vendored into the tree (`…assets/icons/**`) first, then the user-level
|
|
201
|
+
* cache — and rewrite the calls inline (mutates `entries[].text`). Returns the `// icons:` header
|
|
202
|
+
* payload (distinct packs), or undefined when the project uses no icons. */
|
|
203
|
+
export const applyAppAssetIcons = async (
|
|
204
|
+
entries: AssetIconEntry[],
|
|
205
|
+
opts: { registry?: string, cacheDir?: string } = {},
|
|
206
|
+
): Promise<string | undefined> => {
|
|
207
|
+
const ids = await applyAssetIconMacro(entries, async (wanted) => {
|
|
208
|
+
const vendored = collectVendoredIcons(entries)
|
|
209
|
+
const misses = wanted.filter((id) => !vendored.has(id))
|
|
210
|
+
const fetched = misses.length ? await cacheIcons(misses, opts) : new Map<string, string>()
|
|
211
|
+
return new Map(wanted.map((id) => [id, vendored.get(id) ?? fetched.get(id)!]))
|
|
212
|
+
})
|
|
213
|
+
return ids.length ? buildIconsHeaderValue(ids) : undefined
|
|
214
|
+
}
|