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.
Files changed (270) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +1069 -396
  3. package/package.json +14 -13
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk-types.json +1 -1
  6. package/runtime/web/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
  7. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  8. package/runtime/web/assets/basis-C64VHDVD.js +1 -0
  9. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  10. package/runtime/web/assets/createViewerLite-BvHobVhp.js +852 -0
  11. package/runtime/web/assets/draco-BiISTFcR.js +118 -0
  12. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  13. package/runtime/web/assets/index-CfNCCBjN.js +2 -0
  14. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +810 -0
  15. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +8 -0
  16. package/runtime/web/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  17. package/runtime/web/assets/worker-Caf-yYEI.js +2 -0
  18. package/runtime/web/embed.html +32 -0
  19. package/runtime/web/embed.js +171 -0
  20. package/src/api.ts +302 -0
  21. package/src/browserAuth.ts +87 -0
  22. package/src/cmgenTool.ts +104 -0
  23. package/src/commands/app.ts +896 -0
  24. package/src/commands/appAndroid.ts +603 -0
  25. package/src/commands/appDesktop.ts +339 -0
  26. package/src/commands/appDesktopMac.ts +470 -0
  27. package/src/commands/appIcon.ts +187 -0
  28. package/src/commands/appShared.ts +542 -0
  29. package/src/commands/appTemplates.ts +907 -0
  30. package/src/commands/appTemplatesAndroid.ts +599 -0
  31. package/src/commands/appTemplatesGradlew.ts +9 -0
  32. package/src/commands/assets.ts +28 -0
  33. package/src/commands/clone.ts +60 -0
  34. package/src/commands/compile.ts +253 -0
  35. package/src/commands/create.ts +59 -0
  36. package/src/commands/design.ts +863 -0
  37. package/src/commands/designTemplates.ts +11 -0
  38. package/src/commands/desktop.ts +127 -0
  39. package/src/commands/dev.ts +236 -0
  40. package/src/commands/diff.ts +59 -0
  41. package/src/commands/init.ts +191 -0
  42. package/src/commands/install.ts +198 -0
  43. package/src/commands/lightmap.ts +290 -0
  44. package/src/commands/link.ts +147 -0
  45. package/src/commands/login.ts +24 -0
  46. package/src/commands/navmesh.ts +225 -0
  47. package/src/commands/pn.ts +305 -0
  48. package/src/commands/projectTemplates.ts +130 -0
  49. package/src/commands/pull.ts +109 -0
  50. package/src/commands/push.ts +173 -0
  51. package/src/commands/render.ts +416 -0
  52. package/src/commands/scene.ts +148 -0
  53. package/src/commands/shaders.ts +157 -0
  54. package/src/commands/status.ts +29 -0
  55. package/src/commands/test.ts +371 -0
  56. package/src/commands/thumbs.ts +178 -0
  57. package/src/commands/types.ts +87 -0
  58. package/src/commands/update.ts +190 -0
  59. package/src/compile/assetIcons.ts +214 -0
  60. package/src/compile/collect.ts +46 -0
  61. package/src/compile/collectLocal.ts +37 -0
  62. package/src/compile/designCompile.ts +104 -0
  63. package/src/compile/fonts.ts +156 -0
  64. package/src/compile/headlessBundle.ts +129 -0
  65. package/src/compile/nativeStack.ts +21 -0
  66. package/src/compile/projectCompile.ts +109 -0
  67. package/src/compile/sceneCompile.ts +113 -0
  68. package/src/compile/screenEntry.ts +127 -0
  69. package/src/compile/shaders.ts +245 -0
  70. package/src/config.ts +42 -0
  71. package/src/designMeta.ts +35 -0
  72. package/src/desktopRenderer.ts +545 -0
  73. package/src/desktopScript.ts +276 -0
  74. package/src/dev/clientTemplates.ts +160 -0
  75. package/src/dev/devServer.ts +333 -0
  76. package/src/dev/webRunner.ts +22 -0
  77. package/src/dev/wsServer.ts +144 -0
  78. package/src/distRoot.ts +20 -0
  79. package/src/ignore.ts +163 -0
  80. package/src/index.ts +494 -0
  81. package/src/lecodes-3d-editor.d.ts +41 -0
  82. package/src/lecodes-assets.d.ts +7 -0
  83. package/src/lecodes-design.d.ts +191 -0
  84. package/src/lecodes-renderer.d.ts +131 -0
  85. package/src/localFiles.ts +144 -0
  86. package/src/manifest.ts +46 -0
  87. package/src/matcTool.ts +137 -0
  88. package/src/peers.ts +40 -0
  89. package/src/project.ts +61 -0
  90. package/src/projectEnv.ts +94 -0
  91. package/src/qrcode-terminal.d.ts +9 -0
  92. package/src/releases.ts +125 -0
  93. package/src/serverDiff.ts +78 -0
  94. package/src/textDiff.ts +103 -0
  95. package/src/types.ts +0 -0
  96. package/src/util.ts +146 -0
  97. package/runtime/sdk/animate/animate.ts +0 -238
  98. package/runtime/sdk/animate/bezier.ts +0 -138
  99. package/runtime/sdk/animate/easings.ts +0 -126
  100. package/runtime/sdk/canvas/Canvas.ts +0 -305
  101. package/runtime/sdk/compile/aspectMacro.ts +0 -86
  102. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  103. package/runtime/sdk/compile/assetMacro.ts +0 -146
  104. package/runtime/sdk/compile/assetName.ts +0 -50
  105. package/runtime/sdk/compile/bundler.ts +0 -287
  106. package/runtime/sdk/compile/compileProject.ts +0 -129
  107. package/runtime/sdk/compile/detectEntry.ts +0 -128
  108. package/runtime/sdk/compile/fontMacro.ts +0 -459
  109. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  110. package/runtime/sdk/compile/header.ts +0 -67
  111. package/runtime/sdk/compile/index.ts +0 -100
  112. package/runtime/sdk/compile/libraryImports.ts +0 -52
  113. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  114. package/runtime/sdk/compile/sceneEditor.ts +0 -88
  115. package/runtime/sdk/compile/serverSplit.ts +0 -233
  116. package/runtime/sdk/compile/serverTypes.ts +0 -227
  117. package/runtime/sdk/compile/sfnt.ts +0 -98
  118. package/runtime/sdk/compile/shaderSchema.ts +0 -202
  119. package/runtime/sdk/compile/shaderTargets.ts +0 -81
  120. package/runtime/sdk/compile/sourcemap.ts +0 -25
  121. package/runtime/sdk/core/Aspect.ts +0 -568
  122. package/runtime/sdk/core/InspectorUI.ts +0 -212
  123. package/runtime/sdk/core/StateMachine.ts +0 -308
  124. package/runtime/sdk/core/color.ts +0 -66
  125. package/runtime/sdk/core/compWrite.ts +0 -42
  126. package/runtime/sdk/core/events.ts +0 -38
  127. package/runtime/sdk/core/fields.ts +0 -120
  128. package/runtime/sdk/core/registry.ts +0 -23
  129. package/runtime/sdk/core/signals.ts +0 -277
  130. package/runtime/sdk/core/time.ts +0 -84
  131. package/runtime/sdk/g2/Camera2D.ts +0 -40
  132. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  133. package/runtime/sdk/g2/Node2D.ts +0 -267
  134. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  135. package/runtime/sdk/g2/Physics2D.ts +0 -346
  136. package/runtime/sdk/g2/Scene2D.ts +0 -209
  137. package/runtime/sdk/g2/Shape2D.ts +0 -259
  138. package/runtime/sdk/g2/Sprite.ts +0 -89
  139. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  140. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  141. package/runtime/sdk/g2/Texture2D.ts +0 -47
  142. package/runtime/sdk/g2/Tilemap.ts +0 -41
  143. package/runtime/sdk/g2/Tileset.ts +0 -71
  144. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  145. package/runtime/sdk/g2/autotile.ts +0 -433
  146. package/runtime/sdk/g2/cells.ts +0 -91
  147. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  148. package/runtime/sdk/g2/groups2d.ts +0 -106
  149. package/runtime/sdk/g2/loop.ts +0 -50
  150. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  151. package/runtime/sdk/g2/touch.ts +0 -83
  152. package/runtime/sdk/gl/Camera.ts +0 -160
  153. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  154. package/runtime/sdk/gl/CharacterController.ts +0 -238
  155. package/runtime/sdk/gl/Gearbox.ts +0 -212
  156. package/runtime/sdk/gl/Geometry.ts +0 -279
  157. package/runtime/sdk/gl/IK.ts +0 -193
  158. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  159. package/runtime/sdk/gl/Light.ts +0 -107
  160. package/runtime/sdk/gl/Lightmap.ts +0 -256
  161. package/runtime/sdk/gl/Material.ts +0 -260
  162. package/runtime/sdk/gl/Mesh.ts +0 -83
  163. package/runtime/sdk/gl/Model.ts +0 -124
  164. package/runtime/sdk/gl/NavAgent.ts +0 -337
  165. package/runtime/sdk/gl/NavMesh.ts +0 -397
  166. package/runtime/sdk/gl/Node.ts +0 -350
  167. package/runtime/sdk/gl/Noise.ts +0 -30
  168. package/runtime/sdk/gl/Particles.ts +0 -676
  169. package/runtime/sdk/gl/Physics.ts +0 -222
  170. package/runtime/sdk/gl/Plane.ts +0 -53
  171. package/runtime/sdk/gl/Ragdoll.ts +0 -270
  172. package/runtime/sdk/gl/Ray.ts +0 -16
  173. package/runtime/sdk/gl/Scene.ts +0 -486
  174. package/runtime/sdk/gl/Shape.ts +0 -409
  175. package/runtime/sdk/gl/Terrain.ts +0 -1092
  176. package/runtime/sdk/gl/Texture.ts +0 -63
  177. package/runtime/sdk/gl/Trigger.ts +0 -45
  178. package/runtime/sdk/gl/Vehicle.ts +0 -473
  179. package/runtime/sdk/gl/Wheel.ts +0 -240
  180. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  181. package/runtime/sdk/gl/animation/Animator.ts +0 -120
  182. package/runtime/sdk/gl/animation/Layer.ts +0 -31
  183. package/runtime/sdk/gl/animation/Loop.ts +0 -30
  184. package/runtime/sdk/gl/animation/Playback.ts +0 -44
  185. package/runtime/sdk/gl/animation/core.ts +0 -336
  186. package/runtime/sdk/gl/controls.ts +0 -95
  187. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  188. package/runtime/sdk/gl/scenarios.ts +0 -291
  189. package/runtime/sdk/gl/state.ts +0 -6
  190. package/runtime/sdk/gl/terrainMesh.ts +0 -219
  191. package/runtime/sdk/gl/touch.ts +0 -68
  192. package/runtime/sdk/inject.ts +0 -197
  193. package/runtime/sdk/math/Mathf.ts +0 -118
  194. package/runtime/sdk/math/mat4.ts +0 -278
  195. package/runtime/sdk/math/quat.ts +0 -232
  196. package/runtime/sdk/math/vec.ts +0 -255
  197. package/runtime/sdk/net/codec.ts +0 -119
  198. package/runtime/sdk/net/core.ts +0 -384
  199. package/runtime/sdk/net/index.ts +0 -181
  200. package/runtime/sdk/net/replication.ts +0 -622
  201. package/runtime/sdk/plugins/camera.ts +0 -81
  202. package/runtime/sdk/plugins/geolocation.ts +0 -123
  203. package/runtime/sdk/plugins/oauth.ts +0 -61
  204. package/runtime/sdk/plugins/permission.ts +0 -7
  205. package/runtime/sdk/plugins/push.ts +0 -132
  206. package/runtime/sdk/plugins/qr.ts +0 -73
  207. package/runtime/sdk/plugins/service.ts +0 -47
  208. package/runtime/sdk/runtime/app.ts +0 -101
  209. package/runtime/sdk/runtime/appEvents.ts +0 -54
  210. package/runtime/sdk/runtime/channel.ts +0 -61
  211. package/runtime/sdk/runtime/clipboard.ts +0 -20
  212. package/runtime/sdk/runtime/datetime.ts +0 -329
  213. package/runtime/sdk/runtime/device.ts +0 -293
  214. package/runtime/sdk/runtime/fetch.ts +0 -77
  215. package/runtime/sdk/runtime/files.ts +0 -23
  216. package/runtime/sdk/runtime/input.ts +0 -175
  217. package/runtime/sdk/runtime/media.ts +0 -111
  218. package/runtime/sdk/runtime/misc.ts +0 -16
  219. package/runtime/sdk/runtime/net.ts +0 -36
  220. package/runtime/sdk/runtime/rpc.ts +0 -218
  221. package/runtime/sdk/runtime/service.ts +0 -83
  222. package/runtime/sdk/runtime/share.ts +0 -9
  223. package/runtime/sdk/runtime/storage.ts +0 -13
  224. package/runtime/sdk/runtime/touch.ts +0 -76
  225. package/runtime/sdk/scene/defineScene.ts +0 -1353
  226. package/runtime/sdk/scene/editorPlugins.ts +0 -110
  227. package/runtime/sdk/scene/gizmos.ts +0 -148
  228. package/runtime/sdk/scene/grammar.ts +0 -120
  229. package/runtime/sdk/scene/material.ts +0 -188
  230. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  231. package/runtime/sdk/server/auth/global.ts +0 -80
  232. package/runtime/sdk/server/auth/host.ts +0 -318
  233. package/runtime/sdk/server/auth/models.ts +0 -83
  234. package/runtime/sdk/server/auth/types.ts +0 -50
  235. package/runtime/sdk/server/channel.ts +0 -56
  236. package/runtime/sdk/server/context.ts +0 -36
  237. package/runtime/sdk/server/db/defineDb.ts +0 -237
  238. package/runtime/sdk/server/db/fields.ts +0 -132
  239. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  240. package/runtime/sdk/server/db/index.ts +0 -7
  241. package/runtime/sdk/server/db/marci/query.ts +0 -412
  242. package/runtime/sdk/server/db/types.ts +0 -202
  243. package/runtime/sdk/server/errors.ts +0 -12
  244. package/runtime/sdk/server/host.ts +0 -74
  245. package/runtime/sdk/server/inject.ts +0 -13
  246. package/runtime/sdk/server/runtime.ts +0 -133
  247. package/runtime/sdk/server/validate.ts +0 -87
  248. package/runtime/sdk/ui/NativeView.ts +0 -142
  249. package/runtime/sdk/ui/UI.ts +0 -39
  250. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  251. package/runtime/sdk/ui/UIButton.ts +0 -101
  252. package/runtime/sdk/ui/UIContainer.ts +0 -60
  253. package/runtime/sdk/ui/UIImage.ts +0 -83
  254. package/runtime/sdk/ui/UIInput.ts +0 -185
  255. package/runtime/sdk/ui/UIModal.ts +0 -139
  256. package/runtime/sdk/ui/UINode.ts +0 -830
  257. package/runtime/sdk/ui/UIPager.ts +0 -362
  258. package/runtime/sdk/ui/UIPopover.ts +0 -100
  259. package/runtime/sdk/ui/UIScreen.ts +0 -123
  260. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  261. package/runtime/sdk/ui/UISpacer.ts +0 -14
  262. package/runtime/sdk/ui/UITabs.ts +0 -236
  263. package/runtime/sdk/ui/UIText.ts +0 -51
  264. package/runtime/sdk/ui/UIVideo.ts +0 -88
  265. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  266. package/runtime/sdk/ui/UIWidget.ts +0 -127
  267. package/runtime/sdk/ui/fonts.ts +0 -13
  268. package/runtime/sdk/ui/presentable.ts +0 -117
  269. package/runtime/sdk/ui/router.ts +0 -132
  270. 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")