lecodes-cli 0.18.2 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/dist/index.js +1866 -301
  2. package/package.json +14 -12
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk-types.json +1 -1
  5. package/src/api.ts +302 -0
  6. package/src/browserAuth.ts +87 -0
  7. package/src/cmgenTool.ts +104 -0
  8. package/src/commands/app.ts +891 -0
  9. package/src/commands/appAndroid.ts +603 -0
  10. package/src/commands/appDesktop.ts +326 -0
  11. package/src/commands/appDesktopMac.ts +470 -0
  12. package/src/commands/appIcon.ts +187 -0
  13. package/src/commands/appShared.ts +448 -0
  14. package/src/commands/appTemplates.ts +883 -0
  15. package/src/commands/appTemplatesAndroid.ts +599 -0
  16. package/src/commands/appTemplatesGradlew.ts +9 -0
  17. package/src/commands/assets.ts +28 -0
  18. package/src/commands/clone.ts +60 -0
  19. package/src/commands/compile.ts +253 -0
  20. package/src/commands/create.ts +59 -0
  21. package/src/commands/design.ts +863 -0
  22. package/src/commands/designTemplates.ts +11 -0
  23. package/src/commands/desktop.ts +122 -0
  24. package/src/commands/dev.ts +214 -0
  25. package/src/commands/diff.ts +59 -0
  26. package/src/commands/init.ts +191 -0
  27. package/src/commands/install.ts +198 -0
  28. package/src/commands/lightmap.ts +290 -0
  29. package/src/commands/link.ts +147 -0
  30. package/src/commands/login.ts +24 -0
  31. package/src/commands/navmesh.ts +225 -0
  32. package/src/commands/pn.ts +305 -0
  33. package/src/commands/projectTemplates.ts +129 -0
  34. package/src/commands/pull.ts +109 -0
  35. package/src/commands/push.ts +173 -0
  36. package/src/commands/render.ts +414 -0
  37. package/src/commands/scene.ts +148 -0
  38. package/src/commands/shaders.ts +157 -0
  39. package/src/commands/status.ts +29 -0
  40. package/src/commands/test.ts +370 -0
  41. package/src/commands/thumbs.ts +178 -0
  42. package/src/commands/types.ts +87 -0
  43. package/src/commands/update.ts +190 -0
  44. package/src/compile/assetIcons.ts +214 -0
  45. package/src/compile/collect.ts +46 -0
  46. package/src/compile/collectLocal.ts +37 -0
  47. package/src/compile/designCompile.ts +104 -0
  48. package/src/compile/fonts.ts +156 -0
  49. package/src/compile/headlessBundle.ts +129 -0
  50. package/src/compile/nativeStack.ts +21 -0
  51. package/src/compile/projectCompile.ts +109 -0
  52. package/src/compile/sceneCompile.ts +113 -0
  53. package/src/compile/screenEntry.ts +127 -0
  54. package/src/compile/shaders.ts +245 -0
  55. package/src/config.ts +42 -0
  56. package/src/designMeta.ts +35 -0
  57. package/src/desktopRenderer.ts +532 -0
  58. package/src/desktopScript.ts +276 -0
  59. package/src/dev/clientTemplates.ts +160 -0
  60. package/src/dev/devServer.ts +292 -0
  61. package/src/dev/wsServer.ts +144 -0
  62. package/src/distRoot.ts +20 -0
  63. package/src/ignore.ts +163 -0
  64. package/src/index.ts +491 -0
  65. package/src/lecodes-3d-editor.d.ts +41 -0
  66. package/src/lecodes-assets.d.ts +7 -0
  67. package/src/lecodes-design.d.ts +191 -0
  68. package/src/lecodes-renderer.d.ts +131 -0
  69. package/src/localFiles.ts +144 -0
  70. package/src/manifest.ts +46 -0
  71. package/src/matcTool.ts +137 -0
  72. package/src/peers.ts +40 -0
  73. package/src/project.ts +61 -0
  74. package/src/projectEnv.ts +94 -0
  75. package/src/qrcode-terminal.d.ts +9 -0
  76. package/src/releases.ts +125 -0
  77. package/src/serverDiff.ts +78 -0
  78. package/src/textDiff.ts +103 -0
  79. package/src/types.ts +0 -0
  80. package/src/util.ts +146 -0
  81. package/runtime/sdk/animate/animate.ts +0 -238
  82. package/runtime/sdk/animate/bezier.ts +0 -138
  83. package/runtime/sdk/animate/easings.ts +0 -126
  84. package/runtime/sdk/canvas/Canvas.ts +0 -305
  85. package/runtime/sdk/core/Aspect.ts +0 -512
  86. package/runtime/sdk/core/InspectorUI.ts +0 -212
  87. package/runtime/sdk/core/color.ts +0 -66
  88. package/runtime/sdk/core/compWrite.ts +0 -42
  89. package/runtime/sdk/core/events.ts +0 -38
  90. package/runtime/sdk/core/fields.ts +0 -120
  91. package/runtime/sdk/core/registry.ts +0 -23
  92. package/runtime/sdk/core/signals.ts +0 -277
  93. package/runtime/sdk/core/time.ts +0 -81
  94. package/runtime/sdk/g2/Camera2D.ts +0 -40
  95. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  96. package/runtime/sdk/g2/Node2D.ts +0 -267
  97. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  98. package/runtime/sdk/g2/Physics2D.ts +0 -346
  99. package/runtime/sdk/g2/Scene2D.ts +0 -209
  100. package/runtime/sdk/g2/Shape2D.ts +0 -259
  101. package/runtime/sdk/g2/Sprite.ts +0 -89
  102. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  103. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  104. package/runtime/sdk/g2/Texture2D.ts +0 -47
  105. package/runtime/sdk/g2/Tilemap.ts +0 -41
  106. package/runtime/sdk/g2/Tileset.ts +0 -71
  107. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  108. package/runtime/sdk/g2/autotile.ts +0 -433
  109. package/runtime/sdk/g2/cells.ts +0 -91
  110. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  111. package/runtime/sdk/g2/groups2d.ts +0 -106
  112. package/runtime/sdk/g2/loop.ts +0 -50
  113. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  114. package/runtime/sdk/g2/touch.ts +0 -83
  115. package/runtime/sdk/gl/Camera.ts +0 -160
  116. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  117. package/runtime/sdk/gl/CharacterController.ts +0 -238
  118. package/runtime/sdk/gl/Gearbox.ts +0 -212
  119. package/runtime/sdk/gl/Geometry.ts +0 -279
  120. package/runtime/sdk/gl/IK.ts +0 -193
  121. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  122. package/runtime/sdk/gl/Light.ts +0 -99
  123. package/runtime/sdk/gl/Lightmap.ts +0 -179
  124. package/runtime/sdk/gl/Material.ts +0 -245
  125. package/runtime/sdk/gl/Mesh.ts +0 -83
  126. package/runtime/sdk/gl/Model.ts +0 -64
  127. package/runtime/sdk/gl/Node.ts +0 -350
  128. package/runtime/sdk/gl/Noise.ts +0 -30
  129. package/runtime/sdk/gl/Particles.ts +0 -676
  130. package/runtime/sdk/gl/Physics.ts +0 -222
  131. package/runtime/sdk/gl/Plane.ts +0 -53
  132. package/runtime/sdk/gl/Ray.ts +0 -16
  133. package/runtime/sdk/gl/Scene.ts +0 -479
  134. package/runtime/sdk/gl/Shape.ts +0 -377
  135. package/runtime/sdk/gl/Texture.ts +0 -46
  136. package/runtime/sdk/gl/Trigger.ts +0 -45
  137. package/runtime/sdk/gl/Vehicle.ts +0 -473
  138. package/runtime/sdk/gl/Wheel.ts +0 -240
  139. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  140. package/runtime/sdk/gl/animation/Animator.ts +0 -87
  141. package/runtime/sdk/gl/animation/Layer.ts +0 -29
  142. package/runtime/sdk/gl/animation/Loop.ts +0 -25
  143. package/runtime/sdk/gl/animation/Playback.ts +0 -43
  144. package/runtime/sdk/gl/animation/core.ts +0 -294
  145. package/runtime/sdk/gl/controls.ts +0 -95
  146. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  147. package/runtime/sdk/gl/scenarios.ts +0 -291
  148. package/runtime/sdk/gl/state.ts +0 -6
  149. package/runtime/sdk/gl/touch.ts +0 -68
  150. package/runtime/sdk/inject.ts +0 -186
  151. package/runtime/sdk/math/Mathf.ts +0 -118
  152. package/runtime/sdk/math/mat4.ts +0 -278
  153. package/runtime/sdk/math/quat.ts +0 -232
  154. package/runtime/sdk/math/vec.ts +0 -255
  155. package/runtime/sdk/plugins/camera.ts +0 -81
  156. package/runtime/sdk/plugins/geolocation.ts +0 -123
  157. package/runtime/sdk/plugins/oauth.ts +0 -61
  158. package/runtime/sdk/plugins/permission.ts +0 -7
  159. package/runtime/sdk/plugins/push.ts +0 -132
  160. package/runtime/sdk/plugins/qr.ts +0 -73
  161. package/runtime/sdk/plugins/service.ts +0 -47
  162. package/runtime/sdk/runtime/app.ts +0 -101
  163. package/runtime/sdk/runtime/appEvents.ts +0 -54
  164. package/runtime/sdk/runtime/channel.ts +0 -61
  165. package/runtime/sdk/runtime/clipboard.ts +0 -20
  166. package/runtime/sdk/runtime/datetime.ts +0 -329
  167. package/runtime/sdk/runtime/device.ts +0 -293
  168. package/runtime/sdk/runtime/fetch.ts +0 -77
  169. package/runtime/sdk/runtime/files.ts +0 -23
  170. package/runtime/sdk/runtime/input.ts +0 -175
  171. package/runtime/sdk/runtime/media.ts +0 -111
  172. package/runtime/sdk/runtime/misc.ts +0 -16
  173. package/runtime/sdk/runtime/net.ts +0 -36
  174. package/runtime/sdk/runtime/rpc.ts +0 -218
  175. package/runtime/sdk/runtime/service.ts +0 -83
  176. package/runtime/sdk/runtime/share.ts +0 -9
  177. package/runtime/sdk/runtime/storage.ts +0 -13
  178. package/runtime/sdk/runtime/touch.ts +0 -76
  179. package/runtime/sdk/scene/defineScene.ts +0 -1227
  180. package/runtime/sdk/scene/editorPlugins.ts +0 -92
  181. package/runtime/sdk/scene/gizmos.ts +0 -148
  182. package/runtime/sdk/scene/grammar.ts +0 -120
  183. package/runtime/sdk/scene/material.ts +0 -188
  184. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  185. package/runtime/sdk/server/auth/global.ts +0 -80
  186. package/runtime/sdk/server/auth/host.ts +0 -318
  187. package/runtime/sdk/server/auth/models.ts +0 -83
  188. package/runtime/sdk/server/auth/types.ts +0 -50
  189. package/runtime/sdk/server/channel.ts +0 -56
  190. package/runtime/sdk/server/context.ts +0 -36
  191. package/runtime/sdk/server/db/defineDb.ts +0 -237
  192. package/runtime/sdk/server/db/fields.ts +0 -132
  193. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  194. package/runtime/sdk/server/db/index.ts +0 -7
  195. package/runtime/sdk/server/db/marci/query.ts +0 -412
  196. package/runtime/sdk/server/db/types.ts +0 -202
  197. package/runtime/sdk/server/errors.ts +0 -12
  198. package/runtime/sdk/server/host.ts +0 -74
  199. package/runtime/sdk/server/inject.ts +0 -13
  200. package/runtime/sdk/server/runtime.ts +0 -133
  201. package/runtime/sdk/server/validate.ts +0 -87
  202. package/runtime/sdk/ui/NativeView.ts +0 -142
  203. package/runtime/sdk/ui/UI.ts +0 -39
  204. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  205. package/runtime/sdk/ui/UIButton.ts +0 -101
  206. package/runtime/sdk/ui/UIContainer.ts +0 -60
  207. package/runtime/sdk/ui/UIImage.ts +0 -83
  208. package/runtime/sdk/ui/UIInput.ts +0 -185
  209. package/runtime/sdk/ui/UIModal.ts +0 -139
  210. package/runtime/sdk/ui/UINode.ts +0 -826
  211. package/runtime/sdk/ui/UIPager.ts +0 -362
  212. package/runtime/sdk/ui/UIPopover.ts +0 -100
  213. package/runtime/sdk/ui/UIScreen.ts +0 -123
  214. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  215. package/runtime/sdk/ui/UISpacer.ts +0 -14
  216. package/runtime/sdk/ui/UITabs.ts +0 -236
  217. package/runtime/sdk/ui/UIText.ts +0 -51
  218. package/runtime/sdk/ui/UIVideo.ts +0 -88
  219. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  220. package/runtime/sdk/ui/UIWidget.ts +0 -127
  221. package/runtime/sdk/ui/fonts.ts +0 -13
  222. package/runtime/sdk/ui/presentable.ts +0 -117
  223. package/runtime/sdk/ui/router.ts +0 -132
  224. package/runtime/sdk/ui/theme.ts +0 -84
package/src/peers.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { createRequire } from "node:module"
3
+ import { dirname, join } from "node:path"
4
+
5
+ /* Optional peer packages a user installs separately — `npm install -g …`, matching how the CLI
6
+ * itself is installed. A bare specifier resolves upward from the importing FILE, so a global CLI
7
+ * only ever sees globally installed peers: a project-local `-D` install is invisible to it, and
8
+ * under `npx` neither works (pass every peer as its own `-p`). `--version` reports each one's
9
+ * installed version so a stale/mismatched copy — the usual cause of "needs X" errors or silent
10
+ * render failures like a design screen coming back empty — is visible at a glance. The probe is an
11
+ * export subpath that resolves to a real file in BOTH the published package and the workspace
12
+ * source, so version reporting works whether the CLI runs from npm or straight from the monorepo. */
13
+ export const OPTIONAL_PEERS: [name: string, probe: string][] = [
14
+ ["lecodes-renderer", "lecodes-renderer/headless"],
15
+ ["lecodes-design", "lecodes-design/server"],
16
+ ["lecodes-3d-editor", "lecodes-3d-editor/server"],
17
+ ["lecodes-assets", "lecodes-assets/cli"],
18
+ ]
19
+
20
+ /** Installed version of an optional peer, or null when it isn't installed. Resolves the probe
21
+ * subpath, then walks up to the nearest package.json that actually names the package (skipping any
22
+ * nested ones). Never throws — a standalone binary with no node_modules reports every peer absent. */
23
+ export const peerVersion = (name: string, probe: string): string | null => {
24
+ try {
25
+ const require = createRequire(import.meta.url)
26
+ let dir = dirname(require.resolve(probe))
27
+ for (let depth = 0; depth < 16; depth++) {
28
+ const manifest = join(dir, "package.json")
29
+ if (existsSync(manifest)) {
30
+ const parsed = JSON.parse(readFileSync(manifest, "utf8")) as { name?: string, version?: string }
31
+ if (parsed.name === name) return parsed.version ?? "unknown"
32
+ }
33
+ const parent = dirname(dir)
34
+ if (parent === dir) break
35
+ dir = parent
36
+ }
37
+ } catch { /* not installed, or not resolvable (e.g. the standalone binary) */ }
38
+ return null
39
+ }
40
+
package/src/project.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { dirname, join } from "node:path"
2
+ import { mkdirSync, writeFileSync } from "node:fs"
3
+ import { downloadBlob, type Project, type ProjectAsset } from "./api"
4
+ import type { FileKind, ManifestFile } from "./manifest"
5
+ import { sha256 } from "./util"
6
+
7
+ /* Writes a project's asset tree to disk and returns the path -> { id, type, sha } map for the
8
+ * manifest. Shared by clone and pull. */
9
+
10
+ const kindOf = (type: ProjectAsset["type"]): FileKind => (type === "file" ? "resource" : type as FileKind)
11
+
12
+ const absOf = (root: string, key: string) => join(root, ...key.replace(/^\//, "").split("/"))
13
+
14
+ /** Build each asset's posix path key ("/a/b.ts"). Assets arrive parents-first. */
15
+ export const buildPaths = (assets: ProjectAsset[]): Map<number, string> => {
16
+ const pathById = new Map<number, string>()
17
+ for (const asset of assets) {
18
+ const parent = asset.parentId !== null ? pathById.get(asset.parentId) : undefined
19
+ pathById.set(asset.id, `${parent ?? ""}/${asset.name}`)
20
+ }
21
+ return pathById
22
+ }
23
+
24
+ /** Write all assets to disk; returns the manifest file map (folders are created but not tracked). */
25
+ export const materializeAssets = async (
26
+ root: string, project: Project, apiUrl: string, token: string,
27
+ onProgress?: (done: number, total: number, path: string) => void,
28
+ ): Promise<Record<string, ManifestFile>> => {
29
+ const pathById = buildPaths(project.assets)
30
+ const files: Record<string, ManifestFile> = {}
31
+
32
+ const total = project.assets.filter(a => a.type !== "folder").length
33
+ let done = 0
34
+
35
+ for (const asset of project.assets) {
36
+ const key = pathById.get(asset.id)!
37
+ const abs = absOf(root, key)
38
+
39
+ if (asset.type === "folder") {
40
+ mkdirSync(abs, { recursive: true })
41
+ continue
42
+ }
43
+
44
+ mkdirSync(dirname(abs), { recursive: true })
45
+ let sha: string
46
+ if (asset.type === "file") {
47
+ if (!asset.file?.src) continue
48
+ const bytes = await downloadBlob(apiUrl, asset.file.src)
49
+ writeFileSync(abs, bytes)
50
+ sha = sha256(bytes)
51
+ } else {
52
+ const text = asset.text ?? ""
53
+ writeFileSync(abs, text)
54
+ sha = sha256(text)
55
+ }
56
+ files[key] = { id: asset.id, type: kindOf(asset.type), sha }
57
+ onProgress?.(++done, total, key)
58
+ }
59
+
60
+ return files
61
+ }
@@ -0,0 +1,94 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { dirname, join, resolve } from "node:path"
4
+
5
+ /*
6
+ * Project `.env` — the CLI's LECODES_* / CREATOR_* knobs kept next to app.json instead of in the
7
+ * shell's environment. Loaded once at startup, before any command runs, from the nearest project
8
+ * root above the cwd (a dir with app.json or .lecodes/); nothing is loaded outside a project.
9
+ *
10
+ * Rules, all chosen so a .env can never surprise:
11
+ * - the REAL environment wins: a key already set in the shell is never overridden — the same
12
+ * precedence every command already documents (flag > env > app.json);
13
+ * - only LECODES_* and CREATOR_* keys are taken. A .env is a per-project config file for THIS
14
+ * tool, not a way to rewrite PATH or HOME for the child processes it spawns;
15
+ * - a value starting with ./ or ../ is resolved against the .env's own directory, so a project
16
+ * can point LECODES_DESKTOP_EXE at a host build checked out next to it and still work from any
17
+ * subdirectory; a leading ~/ is the home dir;
18
+ * - the file is never pushed (ignore.ts BUILTIN_PATTERNS) — LECODES_TOKEN and the Android
19
+ * keystore passwords are exactly what ends up in it.
20
+ * Syntax: KEY=value per line, `export KEY=value` accepted, # comments, single or double quotes
21
+ * (\n, \t and \" unescape inside double quotes only), blank lines ignored.
22
+ */
23
+
24
+ const KEY_RE = /^(LECODES|CREATOR)_[A-Z0-9_]+$/
25
+
26
+ /** Parse .env text into key → value. Pure; unknown/odd lines are skipped, never fatal. */
27
+ export const parseDotEnv = (text: string): Record<string, string> => {
28
+ const out: Record<string, string> = {}
29
+ for (const raw of text.split(/\r?\n/)) {
30
+ const line = raw.trim()
31
+ if (!line || line.startsWith("#")) continue
32
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
33
+ if (!m) continue
34
+ const key = m[1]
35
+ let value = m[2]
36
+ if (value.startsWith('"')) {
37
+ const end = value.indexOf('"', 1)
38
+ value = (end < 0 ? value.slice(1) : value.slice(1, end))
39
+ .replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\")
40
+ } else if (value.startsWith("'")) {
41
+ const end = value.indexOf("'", 1)
42
+ value = end < 0 ? value.slice(1) : value.slice(1, end)
43
+ } else {
44
+ // Unquoted: an inline ` # comment` ends the value.
45
+ const hash = value.search(/\s#/)
46
+ value = (hash >= 0 ? value.slice(0, hash) : value).trim()
47
+ }
48
+ out[key] = value
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** The values a .env contributes to the environment: prefix-filtered, paths resolved against
54
+ * `dir`, and nothing that `env` already has. Pure — the file/process plumbing is in loadProjectEnv. */
55
+ export const applyDotEnv = (
56
+ parsed: Record<string, string>, dir: string, env: Record<string, string | undefined>,
57
+ ): Record<string, string> => {
58
+ const out: Record<string, string> = {}
59
+ for (const [key, raw] of Object.entries(parsed)) {
60
+ if (!KEY_RE.test(key) || env[key] !== undefined) continue
61
+ let value = raw
62
+ if (value.startsWith("./") || value.startsWith("../")) value = resolve(dir, value)
63
+ else if (value === "~" || value.startsWith("~/")) value = join(homedir(), value.slice(1))
64
+ out[key] = value
65
+ }
66
+ return out
67
+ }
68
+
69
+ /** The nearest project root at or above `start`: the first dir with app.json or .lecodes/. */
70
+ export const findEnvRoot = (start: string): string | null => {
71
+ let dir = resolve(start)
72
+ while (true) {
73
+ if (existsSync(join(dir, "app.json")) || existsSync(join(dir, ".lecodes"))) return dir
74
+ const parent = dirname(dir)
75
+ if (parent === dir) return null
76
+ dir = parent
77
+ }
78
+ }
79
+
80
+ /** Load `<project>/.env` into process.env (see the rules above). Returns the keys it set, for
81
+ * diagnostics. Never throws — a broken .env is skipped, not a reason the CLI can't run. */
82
+ export const loadProjectEnv = (cwd = process.cwd()): string[] => {
83
+ try {
84
+ const root = findEnvRoot(cwd)
85
+ if (!root) return []
86
+ const file = join(root, ".env")
87
+ if (!existsSync(file)) return []
88
+ const added = applyDotEnv(parseDotEnv(readFileSync(file, "utf8")), root, process.env)
89
+ for (const [k, v] of Object.entries(added)) process.env[k] = v
90
+ return Object.keys(added)
91
+ } catch {
92
+ return []
93
+ }
94
+ }
@@ -0,0 +1,9 @@
1
+ // qrcode-terminal ships no types — the minimal surface the dev command uses.
2
+ declare module "qrcode-terminal" {
3
+ type QrOptions = { small?: boolean }
4
+ const qrcode: {
5
+ generate(text: string, options: QrOptions, callback: (output: string) => void): void
6
+ generate(text: string, callback?: (output: string) => void): void
7
+ }
8
+ export default qrcode
9
+ }
@@ -0,0 +1,125 @@
1
+ import { createHash } from "node:crypto"
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { spawnSync } from "node:child_process"
5
+ import { CliError } from "./util"
6
+
7
+ /*
8
+ * The S3 release channel the CLI downloads native tools from: the manifest scripts/release/*
9
+ * publishes (releases/versions.json) + the sha256-verified archive download. Consumers:
10
+ * - desktopRenderer.ts the LeCodes Desktop host behind `render --desktop` (product "desktop")
11
+ * - matcTool.ts Filament's matc for local shader compile (product "matc")
12
+ */
13
+
14
+ // Straight off the S3 bucket, NOT the CDN: the manifest must never be stale (an updater reading
15
+ // a cached copy would miss fresh releases), and a ~2 KB JSON doesn't need one. The artifact URLs
16
+ // INSIDE it are whatever publish.ts baked (RELEASES_PUBLIC_BASE) — immutable, so caching is fine there.
17
+ const MANIFEST_URL = "https://storage.yandexcloud.net/le-codes/releases/versions.json"
18
+
19
+ export type ManifestFile = { url: string, size: number, sha256: string }
20
+
21
+ /** Written inside every downloaded cache dir: WHICH artifact it holds. A cache dir is named after
22
+ * the release VERSION, but a version can be republished — the macOS desktop host shipped twice as
23
+ * 1.3.2 — and then the name alone says nothing about the contents. Recording the sha256 is what
24
+ * lets `update` tell "already have it" from "same number, different build". */
25
+ const ARTIFACT_STAMP = ".lecodes-artifact.json"
26
+
27
+ /** The sha256 of the artifact a cache dir was filled from; null for a dir predating the stamp
28
+ * (treat as unknown — i.e. re-download, since its contents can't be vouched for). */
29
+ export const cachedArtifactSha = (dir: string): string | null => {
30
+ try {
31
+ return (JSON.parse(readFileSync(join(dir, ARTIFACT_STAMP), "utf8")) as { sha256?: string }).sha256 ?? null
32
+ } catch {
33
+ return null
34
+ }
35
+ }
36
+ export type ManifestVersion = { version: string, files: Record<string, ManifestFile> }
37
+ type Manifest = { products?: Record<string, { latest: string, versions: ManifestVersion[] }> }
38
+
39
+ /** Positive when b is a newer semver than a (the manifest publisher's ordering, mirrored). */
40
+ export const semverNewer = (a: string, b: string): number => {
41
+ const pa = a.split(/[.-]/).map(Number), pb = b.split(/[.-]/).map(Number)
42
+ for (let i = 0; i < 3; i++) if ((pb[i] ?? 0) !== (pa[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0)
43
+ return 0
44
+ }
45
+
46
+ /** One product from the release manifest (always fetched fresh — see MANIFEST_URL). */
47
+ export const fetchReleaseProduct = async (
48
+ product: string, label: string,
49
+ ): Promise<{ latest: string, versions: ManifestVersion[] }> => {
50
+ let manifest: Manifest
51
+ try {
52
+ const res = await fetch(MANIFEST_URL)
53
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
54
+ manifest = (await res.json()) as Manifest
55
+ } catch (e) {
56
+ throw new CliError(`Can't fetch the release manifest (${MANIFEST_URL}): ${e instanceof Error ? e.message : String(e)}`)
57
+ }
58
+ const entry = manifest.products?.[product]
59
+ if (!entry) throw new CliError(`The release manifest has no ${label} product.`)
60
+ return entry
61
+ }
62
+
63
+ /**
64
+ * Download a manifest artifact, verify its sha256, extract it, and move it into `dest` — staged
65
+ * through a temp sibling so a killed download never leaves a half-cache that later resolves as
66
+ * valid. `validate` inspects the extracted stage and returns an error string when the archive
67
+ * doesn't contain what the caller expects (null = good). A concurrent download winning the final
68
+ * rename is fine as long as `dest` then validates.
69
+ */
70
+ export const downloadReleaseArchive = async (
71
+ file: ManifestFile,
72
+ opts: {
73
+ cacheRoot: string, dest: string, validate: (dir: string) => string | null,
74
+ /** Unpack `archive` into `stage` (default: tar — which reads .zip/.tar.gz everywhere it runs).
75
+ * The macOS desktop host publishes as a DMG, which tar can't open; desktopRenderer passes an
76
+ * hdiutil-based extractor for it. */
77
+ extract?: (archive: string, stage: string) => void,
78
+ },
79
+ ): Promise<void> => {
80
+ let bytes: Buffer
81
+ try {
82
+ const res = await fetch(file.url)
83
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
84
+ bytes = Buffer.from(await res.arrayBuffer())
85
+ } catch (e) {
86
+ throw new CliError(`Download failed (${file.url}): ${e instanceof Error ? e.message : String(e)}`)
87
+ }
88
+ const sha = createHash("sha256").update(bytes).digest("hex")
89
+ if (sha !== file.sha256) throw new CliError(`Downloaded archive is corrupt (sha256 mismatch) — try again.`)
90
+
91
+ // Extract into a temp sibling, then rename into place. `tar` handles .zip too on Windows 10+ (bsdtar).
92
+ mkdirSync(opts.cacheRoot, { recursive: true })
93
+ const stage = mkdtempSync(join(opts.cacheRoot, "dl-"))
94
+ try {
95
+ const archiveName = file.url.split("/").pop() ?? "archive"
96
+ const archive = join(stage, archiveName)
97
+ writeFileSync(archive, bytes)
98
+ if (opts.extract) {
99
+ opts.extract(archive, stage)
100
+ } else {
101
+ // On Windows use the SYSTEM tar (bsdtar, Windows 10+) by absolute path — it's the one that
102
+ // extracts .zip too, and a Git Bash PATH would otherwise resolve GNU tar, which can't read
103
+ // zip AND parses the `:` in `C:\…` as a remote-host separator. Relative path + cwd keeps the
104
+ // argv drive-letter-free for the GNU-tar fallback (PATH) on other setups.
105
+ const winTar = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe")
106
+ const tarExe = process.platform === "win32" && existsSync(winTar) ? winTar : "tar"
107
+ const tar = spawnSync(tarExe, ["-xf", archiveName], { cwd: stage, stdio: ["ignore", "ignore", "pipe"] })
108
+ if (tar.status !== 0) throw new CliError(`Couldn't extract the archive: ${tar.stderr?.toString() ?? "tar failed"}`)
109
+ }
110
+ rmSync(archive)
111
+ const bad = opts.validate(stage)
112
+ if (bad) throw new CliError(bad)
113
+ // Stamp before the rename, so a cache dir can never exist without saying what it holds.
114
+ writeFileSync(join(stage, ARTIFACT_STAMP), JSON.stringify({ sha256: file.sha256, url: file.url }, null, 2))
115
+ try {
116
+ renameSync(stage, opts.dest)
117
+ } catch {
118
+ let destBad: string | null
119
+ try { destBad = opts.validate(opts.dest) } catch { destBad = "missing" }
120
+ if (destBad) throw new CliError(`Couldn't move the download into ${opts.dest}.`) // a concurrent download won the race → fine
121
+ }
122
+ } finally {
123
+ rmSync(stage, { recursive: true, force: true })
124
+ }
125
+ }
@@ -0,0 +1,78 @@
1
+ import { readFileSync } from "node:fs"
2
+ import type { Project, ProjectAsset } from "./api"
3
+ import type { LocalScan } from "./localFiles"
4
+ import { classifyKind, isTextKind } from "./localFiles"
5
+ import { buildPaths } from "./project"
6
+ import { basename } from "node:path"
7
+
8
+ /*
9
+ * Compares the local working tree against the project's current server state — the basis for
10
+ * `lecodes diff` and the `push --force` overwrite preview. Status is local-relative:
11
+ * added — present locally, absent on the server (a `push` would create it)
12
+ * removed — present on the server, absent locally (a `push` would delete it)
13
+ * modified — present on both with differing content (a `push` would overwrite the server copy)
14
+ *
15
+ * Text content (text/shader/scene assets) is compared exactly and carried for a line diff. Binary
16
+ * assets are compared by size only — true server-side binary changes aren't detected (noted to the
17
+ * user), but local binary edits and presence differences are.
18
+ */
19
+
20
+ export type ServerDiffStatus = "added" | "removed" | "modified"
21
+
22
+ export type ServerDiffEntry = {
23
+ path: string
24
+ status: ServerDiffStatus
25
+ binary: boolean
26
+ /** For text entries: the server-side text (the diff's "old" side); "" when absent on the server. */
27
+ serverText?: string
28
+ /** For text entries: the local text (the diff's "new" side); "" when absent locally. */
29
+ localText?: string
30
+ }
31
+
32
+ const isTextAsset = (t: ProjectAsset["type"]) => t === "text" || t === "shader" || t === "scene"
33
+
34
+ /** Diff the local scan against the server `project`. Sorted by path. */
35
+ export const computeServerDiff = (project: Project, scan: LocalScan): ServerDiffEntry[] => {
36
+ const pathById = buildPaths(project.assets)
37
+ const serverByPath = new Map<string, ProjectAsset>()
38
+ for (const asset of project.assets) {
39
+ if (asset.type === "folder") continue
40
+ serverByPath.set(pathById.get(asset.id)!, asset)
41
+ }
42
+ const localByPath = new Map(scan.files.map(f => [f.path, f]))
43
+
44
+ const entries: ServerDiffEntry[] = []
45
+ const paths = new Set<string>([...serverByPath.keys(), ...localByPath.keys()])
46
+
47
+ for (const path of [...paths].sort()) {
48
+ const server = serverByPath.get(path)
49
+ const local = localByPath.get(path)
50
+
51
+ if (local && !server) {
52
+ const text = isTextKind(classifyKind(basename(path)))
53
+ entries.push({ path, status: "added", binary: !text, serverText: "", localText: text ? readFileSync(local.absPath, "utf8") : undefined })
54
+ continue
55
+ }
56
+ if (server && !local) {
57
+ const text = isTextAsset(server.type)
58
+ entries.push({ path, status: "removed", binary: !text, serverText: text ? (server.text ?? "") : undefined, localText: "" })
59
+ continue
60
+ }
61
+ // present on both
62
+ if (server && local) {
63
+ if (isTextAsset(server.type)) {
64
+ const serverText = server.text ?? ""
65
+ const localText = readFileSync(local.absPath, "utf8")
66
+ if (serverText !== localText) entries.push({ path, status: "modified", binary: false, serverText, localText })
67
+ } else {
68
+ // Binary: compare by size when the server reports one; otherwise can't tell → skip quietly.
69
+ const serverSize = server.file?.size
70
+ const localSize = Buffer.byteLength(readFileSync(local.absPath))
71
+ if (serverSize !== undefined && serverSize !== localSize) {
72
+ entries.push({ path, status: "modified", binary: true })
73
+ }
74
+ }
75
+ }
76
+ }
77
+ return entries
78
+ }
@@ -0,0 +1,103 @@
1
+ import { c } from "./util"
2
+
3
+ /* A small line-level unified diff (LCS-based) for `lecodes diff`. Good enough for source files;
4
+ * for very large inputs it bails out to a one-line summary to avoid O(n·m) blow-up. */
5
+
6
+ const MAX_LINES = 4000
7
+
8
+ type Op = { tag: " " | "-" | "+", line: string }
9
+
10
+ /** Longest-common-subsequence over lines → a sequence of context/-/+ ops. */
11
+ const lcsDiff = (a: string[], b: string[]): Op[] => {
12
+ const n = a.length, m = b.length
13
+ // dp[i][j] = LCS length of a[i:], b[j:]
14
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
15
+ for (let i = n - 1; i >= 0; i--) {
16
+ for (let j = m - 1; j >= 0; j--) {
17
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
18
+ }
19
+ }
20
+ const ops: Op[] = []
21
+ let i = 0, j = 0
22
+ while (i < n && j < m) {
23
+ if (a[i] === b[j]) { ops.push({ tag: " ", line: a[i] }); i++; j++ }
24
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ tag: "-", line: a[i] }); i++ }
25
+ else { ops.push({ tag: "+", line: b[j] }); j++ }
26
+ }
27
+ while (i < n) ops.push({ tag: "-", line: a[i++] })
28
+ while (j < m) ops.push({ tag: "+", line: b[j++] })
29
+ return ops
30
+ }
31
+
32
+ const splitLines = (text: string): string[] => {
33
+ if (text === "") return []
34
+ const lines = text.split("\n")
35
+ if (lines[lines.length - 1] === "") lines.pop() // trailing newline isn't a line
36
+ return lines
37
+ }
38
+
39
+ /**
40
+ * Produce a colored unified diff of `oldText` → `newText`. Returns "" when there's no difference.
41
+ * `oldLabel`/`newLabel` head the `--- ` / `+++ ` lines.
42
+ */
43
+ export const unifiedDiff = (
44
+ oldText: string, newText: string, oldLabel: string, newLabel: string, context = 3,
45
+ ): string => {
46
+ if (oldText === newText) return ""
47
+ const a = splitLines(oldText)
48
+ const b = splitLines(newText)
49
+ if (a.length + b.length > MAX_LINES) {
50
+ return c.dim(` (large file — ${a.length} → ${b.length} lines, diff omitted)`)
51
+ }
52
+
53
+ const ops = lcsDiff(a, b)
54
+
55
+ // Group ops into hunks, keeping `context` unchanged lines around each run of changes.
56
+ type Hunk = { aStart: number, bStart: number, lines: Op[] }
57
+ const hunks: Hunk[] = []
58
+ let aLine = 0, bLine = 0 // 1-based counters advanced as we walk ops
59
+ let cur: Hunk | null = null
60
+ let trailingContext = 0
61
+
62
+ for (let k = 0; k < ops.length; k++) {
63
+ const op = ops[k]
64
+ const isChange = op.tag !== " "
65
+ if (isChange) {
66
+ if (!cur) {
67
+ // open a hunk, back-filling up to `context` preceding context lines
68
+ const back: Op[] = []
69
+ let bi = k - 1
70
+ while (bi >= 0 && ops[bi].tag === " " && back.length < context) { back.unshift(ops[bi]); bi-- }
71
+ cur = { aStart: aLine - back.length, bStart: bLine - back.length, lines: [...back] }
72
+ }
73
+ cur.lines.push(op)
74
+ trailingContext = 0
75
+ } else if (cur) {
76
+ cur.lines.push(op)
77
+ if (++trailingContext >= context * 2) {
78
+ // enough trailing context → close the hunk, dropping the surplus tail context
79
+ cur.lines.length -= trailingContext - context
80
+ hunks.push(cur); cur = null; trailingContext = 0
81
+ }
82
+ }
83
+ if (op.tag !== "+") aLine++
84
+ if (op.tag !== "-") bLine++
85
+ }
86
+ if (cur) {
87
+ if (trailingContext > context) cur.lines.length -= trailingContext - context
88
+ hunks.push(cur)
89
+ }
90
+
91
+ const out: string[] = [c.bold(`--- ${oldLabel}`), c.bold(`+++ ${newLabel}`)]
92
+ for (const h of hunks) {
93
+ const aCount = h.lines.filter(o => o.tag !== "+").length
94
+ const bCount = h.lines.filter(o => o.tag !== "-").length
95
+ out.push(c.cyan(`@@ -${h.aStart + 1},${aCount} +${h.bStart + 1},${bCount} @@`))
96
+ for (const o of h.lines) {
97
+ if (o.tag === "+") out.push(c.green("+" + o.line))
98
+ else if (o.tag === "-") out.push(c.red("-" + o.line))
99
+ else out.push(c.dim(" " + o.line))
100
+ }
101
+ }
102
+ return out.join("\n")
103
+ }
package/src/types.ts ADDED
Binary file
package/src/util.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { createHash } from "node:crypto"
2
+ import { createInterface } from "node:readline"
3
+ import { sanitizeAssetName } from "lecodes-sdk/compile/assetName"
4
+
5
+ /* Small dependency-free helpers shared across commands: colored logging, prompts, hashing,
6
+ * posix-path normalization and a minimal argv parser. */
7
+
8
+ const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined
9
+ const paint = (code: string, s: string) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s
10
+ export const c = {
11
+ bold: (s: string) => paint("1", s),
12
+ dim: (s: string) => paint("2", s),
13
+ red: (s: string) => paint("31", s),
14
+ green: (s: string) => paint("32", s),
15
+ yellow: (s: string) => paint("33", s),
16
+ cyan: (s: string) => paint("36", s),
17
+ }
18
+
19
+ export const log = (msg = ""): void => { process.stdout.write(msg + "\n") }
20
+ export const info = (msg: string) => log(msg)
21
+ export const success = (msg: string) => log(c.green("✓ ") + msg)
22
+ export const warn = (msg: string) => log(c.yellow("! ") + msg)
23
+
24
+ // stderr variants — for commands that write a result (JS, data) to stdout, so diagnostics never
25
+ // pollute the piped output.
26
+ export const logErr = (msg = "") => process.stderr.write(msg + "\n")
27
+ export const note = (msg: string) => logErr(c.dim(msg))
28
+ export const warnErr = (msg: string) => logErr(c.yellow("! ") + msg)
29
+
30
+ /** Keep a label short, dropping characters from the FRONT (paths matter most at the end). */
31
+ const ellipsize = (s: string, max: number) => s.length <= max ? s : "…" + s.slice(s.length - max + 1)
32
+
33
+ /** A single, in-place-updating progress line (TTY only; a no-op when piped). */
34
+ export const progress = (done: number, total: number, label = "") => {
35
+ if (!process.stdout.isTTY) return
36
+ const pct = total > 0 ? Math.round((done / total) * 100) : 100
37
+ process.stdout.clearLine(0)
38
+ process.stdout.cursorTo(0)
39
+ process.stdout.write(` ${c.cyan(`${pct}`.padStart(3) + "%")} ${c.dim(`(${done}/${total})`)} ${c.dim(ellipsize(label, 48))}`)
40
+ }
41
+
42
+ /** Finish a progress line with a newline so later output starts cleanly. */
43
+ export const endProgress = () => { if (process.stdout.isTTY) process.stdout.write("\n") }
44
+
45
+ /** A user-facing error: index.ts prints its message without a stack trace and exits non-zero. */
46
+ export class CliError extends Error {
47
+ /** HTTP status, when the failure came from the API. Lets a caller tell "already gone" (404) from
48
+ * "refused" (403) — deleting something the server no longer has is a success, not an error. */
49
+ status?: number
50
+ constructor(message: string, status?: number) {
51
+ super(message)
52
+ this.status = status
53
+ }
54
+ }
55
+
56
+ // Output targets that mean "discard" — so `-o /dev/null` (or Windows NUL) doesn't create a real file.
57
+ const NULL_SINKS = new Set(["/dev/null", "nul", "//./nul"])
58
+ /** True if an `--out` value is a null device, i.e. the output should be thrown away, not written. */
59
+ export const isNullSink = (path: string): boolean =>
60
+ NULL_SINKS.has(path.trim().replace(/\\/g, "/").toLowerCase())
61
+
62
+ export const sha256 = (data: string | Uint8Array): string =>
63
+ createHash("sha256").update(data).digest("hex")
64
+
65
+ /** Normalize any path to a leading-slash, forward-slash, "." / ".." free key (e.g. "/a/b.ts").
66
+ * Each segment gets the platform's canonical spelling (sdk/compile/assetName) — macOS hands out
67
+ * decomposed names ("й" as и + U+0306) from readdir, which would otherwise key a file differently
68
+ * than the same name typed in an import or stored on the server. */
69
+ export const toKey = (path: string): string => {
70
+ const parts = path.replace(/\\/g, "/").split("/").map(p => p.trim()).filter(p => p && p !== ".")
71
+ if (parts.some(p => p === "..")) throw new CliError(`Unsafe path: ${path}`)
72
+ const names = parts.map(p => sanitizeAssetName(p))
73
+ if (names.some(name => !name)) throw new CliError(`Wrong path: ${path}`)
74
+ return "/" + names.join("/")
75
+ }
76
+
77
+ export const prompt = (question: string, fallback?: string): Promise<string> =>
78
+ new Promise(resolve => {
79
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
80
+ const suffix = fallback ? c.dim(` (${fallback})`) : ""
81
+ rl.question(`${question}${suffix}: `, answer => {
82
+ rl.close()
83
+ resolve(answer.trim() || fallback || "")
84
+ })
85
+ })
86
+
87
+ /** Prompt without echoing the input (for passwords / token paste). */
88
+ export const promptHidden = (question: string): Promise<string> =>
89
+ new Promise(resolve => {
90
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true })
91
+ rl.question(`${question}: `, answer => {
92
+ rl.close()
93
+ process.stdout.write("\n")
94
+ resolve(answer.trim())
95
+ })
96
+ // Mute everything written after the prompt line was emitted (the keystroke echoes).
97
+ ;(rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = () => {}
98
+ })
99
+
100
+ export const confirm = async (question: string, fallback = false): Promise<boolean> => {
101
+ const answer = (await prompt(`${question} ${fallback ? "[Y/n]" : "[y/N]"}`)).toLowerCase()
102
+ if (!answer) return fallback
103
+ return answer === "y" || answer === "yes"
104
+ }
105
+
106
+ export type Args = { _: string[], flags: Record<string, string | boolean> }
107
+
108
+ /** Parse argv into positionals + flags. Supports `--key value`, `--key=value`, `--bool`, `-m msg`. */
109
+ // `--look-at -0.15,1,2` / `--scale -1`: a token that starts with "-" followed by a digit (or ".5")
110
+ // is a negative NUMBER (or a vector starting with one), not the next flag.
111
+ const isNegativeNumber = (s: string): boolean => /^-(\d|\.\d)/.test(s)
112
+ const isValue = (s: string): boolean => !s.startsWith("-") || isNegativeNumber(s)
113
+
114
+ export const parseArgs = (argv: string[]): Args => {
115
+ const out: Args = { _: [], flags: {} }
116
+ for (let i = 0; i < argv.length; i++) {
117
+ const arg = argv[i]
118
+ if (arg.startsWith("--")) {
119
+ const eq = arg.indexOf("=")
120
+ if (eq >= 0) { out.flags[arg.slice(2, eq)] = arg.slice(eq + 1); continue }
121
+ const key = arg.slice(2)
122
+ const next = argv[i + 1]
123
+ if (next !== undefined && isValue(next)) { out.flags[key] = next; i++ }
124
+ else out.flags[key] = true
125
+ } else if (arg.startsWith("-") && arg.length > 1 && !isNegativeNumber(arg)) {
126
+ const key = arg.slice(1)
127
+ const next = argv[i + 1]
128
+ if (next !== undefined && isValue(next)) { out.flags[key] = next; i++ }
129
+ else out.flags[key] = true
130
+ } else {
131
+ out._.push(arg)
132
+ }
133
+ }
134
+ return out
135
+ }
136
+
137
+ export const flagStr = (args: Args, ...keys: string[]): string | undefined => {
138
+ for (const key of keys) {
139
+ const v = args.flags[key]
140
+ if (typeof v === "string") return v
141
+ }
142
+ return undefined
143
+ }
144
+
145
+ export const flagBool = (args: Args, ...keys: string[]): boolean =>
146
+ keys.some(key => args.flags[key] === true || args.flags[key] === "true")