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/api.ts ADDED
@@ -0,0 +1,302 @@
1
+ import { CliError } from "./util"
2
+
3
+ /* Thin client over the backend HTTP API. All project/account calls go through `${apiUrl}/api/...`;
4
+ * blob downloads hit `${apiUrl}<src>` (the public /uploads/* route). Mirrors the contract the
5
+ * frontend uses (see packages/frontend/src/api). */
6
+
7
+ export type ProjectAsset = {
8
+ id: number
9
+ name: string
10
+ type: "folder" | "file" | "text" | "shader" | "scene"
11
+ parentId: number | null
12
+ meta?: Record<string, unknown> | null
13
+ text?: string | null
14
+ file?: { src: string, previewSrc?: string | null, size?: number } | null
15
+ shaders?: { platform: string, src: string }[]
16
+ }
17
+
18
+ export type Project = {
19
+ uuid: string
20
+ name: string
21
+ role: "owner" | "developer" | "viewer"
22
+ assets: ProjectAsset[]
23
+ }
24
+
25
+ export type CommitsResponse = { dirty: boolean, headId: number | null }
26
+
27
+ export type SyncFileInput = {
28
+ path: string
29
+ assetId?: number
30
+ type: "text" | "shader" | "scene" | "resource"
31
+ text?: string | null
32
+ blobFileId?: number | null
33
+ }
34
+ export type SyncFolderInput = { path: string }
35
+ export type SyncResponse = {
36
+ created: boolean
37
+ headId: number
38
+ seq?: number
39
+ nodes: { id: number, path: string, type: string, versionId: number | null }[]
40
+ /** Set when the push requested `publish` — whether the deploy (compile → live) succeeded. */
41
+ published?: boolean
42
+ /** A human-readable reason when `publish` was requested but the deploy failed (push still landed). */
43
+ publishError?: string
44
+ }
45
+
46
+ const errorMessage = (body: unknown, status: number): string => {
47
+ if (typeof body === "string" && body) return body
48
+ if (body && typeof body === "object") {
49
+ const b = body as Record<string, unknown>
50
+ if (typeof b.error === "string") return b.error
51
+ if (b.error && typeof b.error === "object") {
52
+ const first = Object.values(b.error)[0] as unknown
53
+ if (typeof first === "string") return first
54
+ if (first && typeof first === "object" && typeof (first as { message?: unknown }).message === "string") {
55
+ return (first as { message: string }).message
56
+ }
57
+ }
58
+ if (typeof b.message === "string") return b.message
59
+ }
60
+ return `Request failed with status ${status}`
61
+ }
62
+
63
+ type RequestOptions = { method?: string, body?: unknown, token?: string }
64
+
65
+ export const apiRequest = async <T = unknown>(apiUrl: string, path: string, opts: RequestOptions = {}): Promise<T> => {
66
+ const headers: Record<string, string> = {}
67
+ const isForm = opts.body instanceof FormData
68
+ if (opts.token) headers["Authorization"] = "Bearer " + opts.token
69
+ if (opts.body !== undefined && !isForm) headers["Content-Type"] = "application/json"
70
+
71
+ let resp: Response
72
+ try {
73
+ resp = await fetch(`${apiUrl}/api${path}`, {
74
+ method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
75
+ headers,
76
+ body: opts.body === undefined ? undefined : isForm ? (opts.body as FormData) : JSON.stringify(opts.body),
77
+ })
78
+ } catch (e) {
79
+ throw new CliError(`Cannot reach ${apiUrl} — is the server URL correct and online?`)
80
+ }
81
+
82
+ const contentType = resp.headers.get("content-type") ?? ""
83
+ const parsed: unknown = contentType.startsWith("application/json") ? await resp.json()
84
+ : contentType.startsWith("text/") ? await resp.text()
85
+ : null
86
+
87
+ if (resp.status >= 400) throw new CliError(errorMessage(parsed, resp.status), resp.status)
88
+ return parsed as T
89
+ }
90
+
91
+ export const login = (apiUrl: string, login: string, password: string) =>
92
+ apiRequest<{ accessToken: string, refreshToken: string }>(apiUrl, "/account/login", { body: { login, password } })
93
+
94
+ export const createAccessToken = (apiUrl: string, jwt: string, name: string) =>
95
+ apiRequest<{ id: number, name: string, prefix: string, token: string }>(apiUrl, "/account/tokens", { token: jwt, body: { name } })
96
+
97
+ export const getAccount = (apiUrl: string, token: string) =>
98
+ apiRequest<{ id: number, name: string, email: string }>(apiUrl, "/account", { token })
99
+
100
+ export const getProject = (apiUrl: string, token: string, uuid: string) =>
101
+ apiRequest<Project>(apiUrl, `/projects/${uuid}`, { token })
102
+
103
+ /** Create a new (empty) project on the server. Returns its uuid. */
104
+ export const createProject = (apiUrl: string, token: string, name: string, teamUuid?: string) =>
105
+ apiRequest<{ uuid: string }>(apiUrl, "/projects", { token, body: { name, teamUuid } })
106
+
107
+ export const getCommits = (apiUrl: string, token: string, uuid: string) =>
108
+ apiRequest<CommitsResponse>(apiUrl, `/projects/${uuid}/commits`, { token })
109
+
110
+ export const getTypes = (apiUrl: string) =>
111
+ apiRequest<Record<string, string>>(apiUrl, "/types")
112
+
113
+ export const uploadBlob = (apiUrl: string, token: string, uuid: string, name: string, bytes: Uint8Array) => {
114
+ const form = new FormData()
115
+ // Cast is type-only (TS 5.7 typed-array generics vs the Blob signature); Blob accepts a Uint8Array.
116
+ form.append("file", new Blob([bytes as unknown as ArrayBuffer]), name)
117
+ return apiRequest<{ id: number, src: string, size: number }>(apiUrl, `/projects/${uuid}/blobs`, { token, body: form })
118
+ }
119
+
120
+ export const sync = (apiUrl: string, token: string, uuid: string, payload: { message: string, files: SyncFileInput[], folders: SyncFolderInput[], publish?: boolean }) =>
121
+ apiRequest<SyncResponse>(apiUrl, `/projects/${uuid}/sync`, { token, body: payload })
122
+
123
+ /** Deploy the project's current server state (compile → live bundle). Used by `push --publish` when
124
+ * there are no file changes to sync. */
125
+ export const publishProject = (apiUrl: string, token: string, uuid: string) =>
126
+ apiRequest<{ published: boolean, size: number }>(apiUrl, `/projects/${uuid}/publish`, { token, body: {} })
127
+
128
+ export type DesignShare = {
129
+ token: string | null
130
+ url: string | null
131
+ /** Whether share-link visitors may WRITE comments (reading is never gated on it). */
132
+ commentsOpen?: boolean
133
+ }
134
+
135
+ /** Current public share-link state for a project's design board. */
136
+ export const getDesignShare = (apiUrl: string, token: string, uuid: string) =>
137
+ apiRequest<DesignShare>(apiUrl, `/projects/${uuid}/design/share`, { token })
138
+
139
+ /** Enable the design share link (idempotent). */
140
+ export const enableDesignShare = (apiUrl: string, token: string, uuid: string) =>
141
+ apiRequest<DesignShare>(apiUrl, `/projects/${uuid}/design/share`, { token, body: {} })
142
+
143
+ /** Revoke the design share link. */
144
+ export const disableDesignShare = (apiUrl: string, token: string, uuid: string) =>
145
+ apiRequest<DesignShare>(apiUrl, `/projects/${uuid}/design/share`, { token, method: "DELETE" })
146
+
147
+ /** Let / stop share-link visitors WRITING comments. Reading the existing conversation is unaffected,
148
+ * so switching this off never hides feedback that already arrived. */
149
+ export const setDesignCommentsOpen = (apiUrl: string, token: string, uuid: string, open: boolean) =>
150
+ apiRequest<{ commentsOpen: boolean }>(apiUrl, `/projects/${uuid}/design/comments/open`, { token, body: { open } })
151
+
152
+ /* ---- Design-board comments (docs/design-comments-plan.md) ---------------------------------
153
+ * The platform is the source of truth; `design/comments/` (one file per thread) is the mirror the
154
+ * running `lecodes design` sync — or `comments pull` — fills. The shapes below are the same
155
+ * document the design package's core defines —
156
+ * spelled out here, like every other DTO in this file, because `lecodes-design` is an optional
157
+ * peer the CLI must typecheck and run without. */
158
+
159
+ export type CommentAnchor = {
160
+ screen?: string
161
+ state?: string
162
+ element?: string
163
+ point?: [number, number]
164
+ world?: [number, number]
165
+ }
166
+ export type CommentMessage = {
167
+ id: string
168
+ author: string
169
+ /** Provenance: `share` = unverified stranger on the link, `ai` = Claude on a member's behalf. */
170
+ origin: "share" | "editor" | "cli" | "ai"
171
+ at: string
172
+ body: string
173
+ editedAt?: string
174
+ mine?: boolean
175
+ }
176
+ export type CommentThread = {
177
+ id: string
178
+ number: number
179
+ anchor: CommentAnchor
180
+ resolved: boolean
181
+ resolvedAt?: string | null
182
+ createdAt: string
183
+ updatedAt: string
184
+ commitId?: number | null
185
+ messages: CommentMessage[]
186
+ mine?: boolean
187
+ }
188
+
189
+ export type DesignCommentsSnapshot = {
190
+ threads: CommentThread[]
191
+ cursor: string | null
192
+ canComment: boolean
193
+ shared?: boolean
194
+ commentsOpen?: boolean
195
+ /** The project's thread-number allocator, so a pull can't hand out a used number. */
196
+ nextNumber?: number
197
+ }
198
+
199
+ export const listDesignComments = (apiUrl: string, token: string, uuid: string) =>
200
+ apiRequest<DesignCommentsSnapshot>(apiUrl, `/projects/${uuid}/design/comments`, { token })
201
+
202
+ /** `origin` is provenance, carried up so a reply Claude wrote doesn't reach the reviewer looking
203
+ * like one the developer typed. The account is the identity either way; the server rejects any
204
+ * value but `cli` / `ai` / `editor`. */
205
+ export const createDesignComment = (
206
+ apiUrl: string, token: string, uuid: string,
207
+ input: { anchor: CommentAnchor, body: string, origin?: CommentMessage["origin"] },
208
+ ) => apiRequest<CommentThread>(apiUrl, `/projects/${uuid}/design/comments`, { token, body: input })
209
+
210
+ export const replyDesignComment = (
211
+ apiUrl: string, token: string, uuid: string, threadId: string, body: string,
212
+ origin?: CommentMessage["origin"],
213
+ ) => apiRequest<CommentThread>(
214
+ apiUrl, `/projects/${uuid}/design/comments/${threadId}/messages`, { token, body: { body, origin } })
215
+
216
+ export const resolveDesignComment = (apiUrl: string, token: string, uuid: string, threadId: string, resolved: boolean) =>
217
+ apiRequest<CommentThread>(apiUrl, `/projects/${uuid}/design/comments/${threadId}`, { token, method: "PATCH", body: { resolved } })
218
+
219
+ export const deleteDesignComment = (apiUrl: string, token: string, uuid: string, threadId: string) =>
220
+ apiRequest<{ ok: boolean }>(apiUrl, `/projects/${uuid}/design/comments/${threadId}`, { token, method: "DELETE" })
221
+
222
+ /** Re-anchor a pin. The anchor goes up whole — `element` is replaced, never merged, or a stale name
223
+ * silently re-anchors the pin to whatever else still answers to it. */
224
+ export const moveDesignComment = (
225
+ apiUrl: string, token: string, uuid: string, threadId: string, anchor: CommentAnchor,
226
+ ) => apiRequest<CommentThread>(apiUrl, `/projects/${uuid}/design/comments/${threadId}`, {
227
+ token,
228
+ method: "PATCH",
229
+ body: { screen: anchor.screen, state: anchor.state, element: anchor.element, point: anchor.point },
230
+ })
231
+
232
+ export const editDesignCommentMessage = (
233
+ apiUrl: string, token: string, uuid: string, threadId: string, messageId: string, body: string,
234
+ ) => apiRequest<CommentThread>(
235
+ apiUrl, `/projects/${uuid}/design/comments/${threadId}/messages/${messageId}`, { token, method: "PATCH", body: { body } })
236
+
237
+ export const deleteDesignCommentMessage = (
238
+ apiUrl: string, token: string, uuid: string, threadId: string, messageId: string,
239
+ ) => apiRequest<{ threadRemoved: boolean }>(
240
+ apiUrl, `/projects/${uuid}/design/comments/${threadId}/messages/${messageId}`, { token, method: "DELETE" })
241
+
242
+ /* ---- Push notifications (`lecodes pn`, docs/push-plan.md) ---------------------------------
243
+ * Every call here authenticates with the stored PAT: /push/send accepts a developer-role user
244
+ * token exactly like a project push key, so test sends need no key at all. Keys are minted for
245
+ * ONE purpose — the developer's own production server — and only an owner can manage them. */
246
+
247
+ export type PushSendPayload = {
248
+ to?: string[]
249
+ users?: string[]
250
+ title: string
251
+ body?: string
252
+ url?: string
253
+ data?: Record<string, unknown>
254
+ badge?: number
255
+ }
256
+ export type PushSendResult = { sent: number, failed: { address: string, reason: string }[] }
257
+
258
+ export type PushDevice = {
259
+ address: string
260
+ platform: "apns" | "fcm" | "webpush"
261
+ environment: "production" | "sandbox"
262
+ hostApp: string
263
+ user: string | null
264
+ createdAt: string
265
+ lastSeenAt: string
266
+ disabledAt: string | null
267
+ }
268
+
269
+ export type PushKey = {
270
+ id: number
271
+ name: string
272
+ prefix: string
273
+ createdAt: string
274
+ lastUsedAt: string | null
275
+ }
276
+ /** Only the mint response carries `key` — the plaintext exists in this one response and nowhere else. */
277
+ export type CreatedPushKey = PushKey & { key: string }
278
+
279
+ export const sendPush = (apiUrl: string, token: string, uuid: string, payload: PushSendPayload) =>
280
+ apiRequest<PushSendResult>(apiUrl, `/projects/${uuid}/push/send`, { token, body: payload })
281
+
282
+ export const listPushDevices = (apiUrl: string, token: string, uuid: string, user?: string) =>
283
+ apiRequest<{ devices: PushDevice[], truncated: boolean }>(
284
+ apiUrl, `/projects/${uuid}/push/registrations${user ? `?user=${encodeURIComponent(user)}` : ""}`, { token })
285
+
286
+ export const forgetPushDevice = (apiUrl: string, token: string, uuid: string, address: string) =>
287
+ apiRequest<void>(apiUrl, `/projects/${uuid}/push/registrations/${encodeURIComponent(address)}`, { token, method: "DELETE" })
288
+
289
+ export const listPushKeys = (apiUrl: string, token: string, uuid: string) =>
290
+ apiRequest<PushKey[]>(apiUrl, `/projects/${uuid}/push/keys`, { token })
291
+
292
+ export const createPushKey = (apiUrl: string, token: string, uuid: string, name?: string) =>
293
+ apiRequest<CreatedPushKey>(apiUrl, `/projects/${uuid}/push/keys`, { token, body: { name } })
294
+
295
+ export const revokePushKey = (apiUrl: string, token: string, uuid: string, id: number) =>
296
+ apiRequest<void>(apiUrl, `/projects/${uuid}/push/keys/${id}`, { token, method: "DELETE" })
297
+
298
+ export const downloadBlob = async (apiUrl: string, src: string): Promise<Uint8Array> => {
299
+ const resp = await fetch(`${apiUrl}${src}`)
300
+ if (!resp.ok) throw new CliError(`Failed to download ${src} (status ${resp.status})`)
301
+ return new Uint8Array(await resp.arrayBuffer())
302
+ }
@@ -0,0 +1,87 @@
1
+ import { createServer } from "node:http"
2
+ import type { AddressInfo } from "node:net"
3
+ import { spawn } from "node:child_process"
4
+ import { randomBytes } from "node:crypto"
5
+ import { hostname } from "node:os"
6
+ import { CliError, c, info, log } from "./util"
7
+
8
+ /*
9
+ * Browser login (loopback OAuth-style flow, like `vercel`/`gh`):
10
+ * 1. start a one-shot HTTP listener on 127.0.0.1:<random port>
11
+ * 2. open the browser to <web>/app/cli-auth?port&state&name
12
+ * 3. the web page (user already signed in) mints a personal access token and redirects it back
13
+ * to the local listener, which captures it and shows a success page.
14
+ * `state` is a random nonce echoed back to prevent another local page from injecting a token.
15
+ */
16
+
17
+ const SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>lecodes</title>
18
+ <body style="font-family:system-ui,sans-serif;background:#0f1115;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0">
19
+ <div style="text-align:center;max-width:420px;padding:32px">
20
+ <h1 style="font-size:20px;margin:0 0 8px">&#10003; Authorized</h1>
21
+ <p style="color:#9aa0aa;margin:0;font-size:14px">You can close this tab and return to your terminal.</p></div>`
22
+
23
+ const errorHtml = (msg: string) => `<!doctype html><meta charset="utf-8"><title>lecodes</title>
24
+ <body style="font-family:system-ui,sans-serif;background:#0f1115;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0">
25
+ <div style="text-align:center;max-width:420px;padding:32px">
26
+ <h1 style="font-size:20px;margin:0 0 8px">Authorization failed</h1>
27
+ <p style="color:#9aa0aa;margin:0;font-size:14px">${msg}</p></div>`
28
+
29
+ /** Open a URL in the default browser (best-effort; the URL is also printed as a fallback). */
30
+ export const openBrowser = (url: string) => {
31
+ let cmd: string
32
+ let args: string[]
33
+ if (process.platform === "win32") { cmd = "rundll32"; args = [ "url.dll,FileProtocolHandler", url ] }
34
+ else if (process.platform === "darwin") { cmd = "open"; args = [ url ] }
35
+ else { cmd = "xdg-open"; args = [ url ] }
36
+ try {
37
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true })
38
+ child.on("error", () => {})
39
+ child.unref()
40
+ } catch { /* fall back to the printed URL */ }
41
+ }
42
+
43
+ /** Run the browser login flow against `webBase` (the app origin) and resolve the access token. */
44
+ export const browserLogin = async (
45
+ webBase: string, timeoutMs = 5 * 60 * 1000, open: (url: string) => void = openBrowser,
46
+ ): Promise<string> => {
47
+ const state = randomBytes(16).toString("hex")
48
+
49
+ let resolve!: (token: string) => void
50
+ let reject!: (err: Error) => void
51
+ const tokenPromise = new Promise<string>((res, rej) => { resolve = res; reject = rej })
52
+
53
+ const server = createServer((req, res) => {
54
+ const url = new URL(req.url ?? "/", "http://127.0.0.1")
55
+ if (url.pathname !== "/") { res.writeHead(404); res.end(); return }
56
+ const token = url.searchParams.get("token")
57
+ if (!token || url.searchParams.get("state") !== state) {
58
+ res.writeHead(400, { "content-type": "text/html" })
59
+ res.end(errorHtml("Invalid or expired authorization."))
60
+ return
61
+ }
62
+ res.writeHead(200, { "content-type": "text/html" })
63
+ res.end(SUCCESS_HTML)
64
+ resolve(token)
65
+ })
66
+
67
+ await new Promise<void>((res, rej) => {
68
+ server.once("error", rej)
69
+ server.listen(0, "127.0.0.1", res)
70
+ })
71
+ const port = (server.address() as AddressInfo).port
72
+
73
+ const timer = setTimeout(() => reject(new CliError("Timed out waiting for browser authorization.")), timeoutMs)
74
+
75
+ const authUrl = `${webBase}/app/cli-auth?port=${port}&state=${state}&name=${encodeURIComponent(hostname())}`
76
+ info("Opening your browser to authorize the CLI…")
77
+ log(c.dim(" " + authUrl))
78
+ log(c.dim(" (waiting — finish in the browser, or press Ctrl+C to cancel)"))
79
+ open(authUrl)
80
+
81
+ try {
82
+ return await tokenPromise
83
+ } finally {
84
+ clearTimeout(timer)
85
+ server.close()
86
+ }
87
+ }
@@ -0,0 +1,104 @@
1
+ import { existsSync, readdirSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { join, resolve } from "node:path"
4
+ import { downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
5
+ import { CliError, note } from "./util"
6
+
7
+ /*
8
+ * Filament's cmgen (environment tool) — the tool behind `lecodes assets sky`, which turns an
9
+ * equirectangular .exr/.hdr into the two cubemaps a scene needs: the sharp one that gets DRAWN as
10
+ * the sky, and the roughness-prefiltered one (+ spherical harmonics) that LIGHTS it.
11
+ *
12
+ * The binary is NOT shipped with the CLI (most projects never bake an environment). It downloads on
13
+ * first use from the same S3 release bucket as the desktop renderer and matc (product "cmgen" in
14
+ * releases/versions.json — published by scripts/release/release-windows.ps1 from the desktop
15
+ * build's in-tree filament host tools, so the KTX it writes always matches the engine the hosts
16
+ * ship). Cache: ~/.lecodes/cmgen/cmgen-<version>/. LECODES_CMGEN overrides resolution entirely
17
+ * (local dev: packages/desktop/build/creator-gl/filament/tools/cmgen/cmgen.exe).
18
+ *
19
+ * The CLI resolves it and hands the path to lecodes-assets through LECODES_CMGEN, so that package
20
+ * stays network-free and still works standalone against a local build.
21
+ *
22
+ * No version pin, same reasoning as matc: the KTX1 layout is tied to the vendored Filament and
23
+ * changes rarely, so resolution takes the newest cached copy, else downloads `latest`.
24
+ */
25
+
26
+ const exeName = () => (process.platform === "win32" ? "cmgen.exe" : "cmgen")
27
+
28
+ const platformKey = (): string => {
29
+ if (process.platform === "win32") return "windows-x64"
30
+ if (process.platform === "linux") return "linux-x64"
31
+ throw new CliError(
32
+ process.platform === "darwin"
33
+ ? "cmgen isn't published for macOS yet — build filament's host tools locally and set LECODES_CMGEN."
34
+ : `cmgen isn't published for ${process.platform}.`,
35
+ )
36
+ }
37
+
38
+ const cacheRoot = () => join(homedir(), ".lecodes", "cmgen")
39
+
40
+ /** Locate the cmgen binary inside a cache/stage dir (the archive may or may not nest a folder). */
41
+ const findCmgenIn = (dir: string): string | null => {
42
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
43
+ const p = join(dir, entry.name)
44
+ if (entry.isFile() && entry.name === exeName()) return p
45
+ if (entry.isDirectory()) { const found = findCmgenIn(p); if (found) return found }
46
+ }
47
+ return null
48
+ }
49
+
50
+ /** Versions with a working cmgen binary in the cache, newest first. */
51
+ export const installedCmgenVersions = (): string[] => {
52
+ try {
53
+ return readdirSync(cacheRoot(), { withFileTypes: true })
54
+ .filter((e) => e.isDirectory() && e.name.startsWith("cmgen-"))
55
+ .map((e) => e.name.slice("cmgen-".length))
56
+ .filter((v) => findCmgenIn(join(cacheRoot(), `cmgen-${v}`)) !== null)
57
+ .sort(semverNewer)
58
+ } catch {
59
+ return []
60
+ }
61
+ }
62
+
63
+ export const fetchCmgenManifest = (): Promise<{ latest: string, versions: ManifestVersion[] }> =>
64
+ fetchReleaseProduct("cmgen", "cmgen (environment tool)")
65
+
66
+ const downloadCmgen = async (version: string, versions: ManifestVersion[], dest: string): Promise<void> => {
67
+ const key = platformKey()
68
+ const file = versions.find((v) => v.version === version)?.files[key]
69
+ if (!file) {
70
+ throw new CliError(
71
+ `cmgen ${version} [${key}] isn't published — build filament's host tools locally and point ` +
72
+ `LECODES_CMGEN at the binary.`,
73
+ )
74
+ }
75
+ note(`Downloading the environment tool (cmgen ${version}, ${(file.size / 1024 / 1024).toFixed(1)} MB)…`)
76
+ await downloadReleaseArchive(file, {
77
+ cacheRoot: cacheRoot(),
78
+ dest,
79
+ validate: (dir) => (findCmgenIn(dir) ? null : "The downloaded archive doesn't contain the cmgen binary."),
80
+ })
81
+ }
82
+
83
+ /** The cmgen binary WITHOUT touching the network: LECODES_CMGEN → newest cached version → null. */
84
+ export const resolveCmgenExeCached = (): string | null => {
85
+ const override = process.env.LECODES_CMGEN
86
+ if (override) {
87
+ if (!existsSync(override)) throw new CliError(`LECODES_CMGEN points at "${override}" which doesn't exist.`)
88
+ return resolve(override)
89
+ }
90
+ const cached = installedCmgenVersions()[0]
91
+ return cached ? findCmgenIn(join(cacheRoot(), `cmgen-${cached}`)) : null
92
+ }
93
+
94
+ /** The cmgen binary: LECODES_CMGEN → newest cached version → download the latest release. */
95
+ export const resolveCmgenExe = async (): Promise<string> => {
96
+ const cached = resolveCmgenExeCached()
97
+ if (cached) return cached
98
+ const { latest, versions } = await fetchCmgenManifest()
99
+ const dir = join(cacheRoot(), `cmgen-${latest}`)
100
+ await downloadCmgen(latest, versions, dir)
101
+ const exe = findCmgenIn(dir)
102
+ if (!exe) throw new CliError(`cmgen cache at ${dir} is broken — delete the folder and retry.`)
103
+ return exe
104
+ }