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
@@ -0,0 +1,542 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
3
+ import { homedir } from "node:os"
4
+ import { dirname, join, resolve } from "node:path"
5
+ import { findProjectRoot } from "../manifest"
6
+ import { DEFAULT_IGNORE_FILE } from "../ignore"
7
+ import { CliError, c, flagStr, log, note, warn, type Args } from "../util"
8
+
9
+ /* Shared plumbing for the `lecodes app` platform arms (app.ts = iOS + dispatch,
10
+ * appAndroid.ts = Android). Everything here moved verbatim out of app.ts when the Android
11
+ * arm landed — behavior for iOS is unchanged; the SDK helpers just grew a repo parameter
12
+ * (lecodes-ios-sdk vs lecodes-android-sdk) and resolvePlugin a platform one. */
13
+
14
+ /** First-party plugin registry: id → GitHub repo holding plugins/<id>/. Third-party plugins
15
+ * use a `github:owner/repo[@tag]` spec in app.json instead (manifest at the repo root). */
16
+ export const FIRST_PARTY_PLUGINS: Record<string, string> = {
17
+ "camera": "letary/lecodes-plugins",
18
+ "qr-scanner": "letary/lecodes-plugins",
19
+ "map": "letary/lecodes-plugins",
20
+ "geolocation": "letary/lecodes-plugins",
21
+ "push": "letary/lecodes-plugins",
22
+ }
23
+
24
+ export interface AppConfig {
25
+ name?: string
26
+ version?: string
27
+ /** Project-root-relative path of a square ≥1024px PNG/JPEG master. When set, every sync
28
+ * renders the platform icons from it (iOS AppIcon + Android adaptive/legacy mipmaps) and
29
+ * those files become CLI-owned like assets/. Absent = icons stay hand-managed; removing
30
+ * the key stops regeneration but deletes nothing. */
31
+ icon?: string
32
+ /** Icon backdrop ("#RGB"/"#RRGGBB", default white): the Android adaptive-icon background
33
+ * layer, and what a transparent master is flattened onto for the opacity-required iOS icon. */
34
+ iconBackground?: string
35
+ plugins?: Record<string, string>
36
+ /** `remote`: lecodesignore-style globs of resources to STREAM from the server instead of
37
+ * embedding in the app (server-backed projects only — a streamed asset needs a server URL). */
38
+ resources?: { remote?: string[] }
39
+ /** Over-the-air JS updates: where the shell checks for newer compiled code at launch
40
+ * (natively, before the JS engine — offline is a no-op, new code applies next launch).
41
+ * `true` = this project's live bundle on the platform (server-backed only); or an https
42
+ * URL to a compiled bundle on any hosting (string or `{ "url": … }`). Absent = no OTA.
43
+ * iOS-only for now — the Android runtime has no native updater yet. */
44
+ update?: boolean | string | { url?: string }
45
+ /** `variant`: SDK product — "auto" (default: sync picks the smallest one covering the engines
46
+ * the compiled bundle actually uses) or core|2d|3d|full to force one.
47
+ * `sdk`: native SDK version — an exact lecodes-ios-sdk release (init pins the newest one,
48
+ * `lecodes app update` moves it), or "latest" to re-resolve on every sync. */
49
+ ios?: { bundleId?: string, teamId?: string, variant?: string, buildNumber?: number, sdk?: string }
50
+ /** `applicationId`: the Android package name (init default: com.example.<slug>).
51
+ * `versionCode`: Play's monotonically increasing build number (versionName comes from the
52
+ * shared `version`). `sdk`: an exact lecodes-android-sdk release or "latest", like ios.sdk.
53
+ * `variant`: SDK artifact — "auto" (default: sync picks the smallest one covering the engines
54
+ * the compiled bundle actually uses) or core|2d|3d|full to force one, like ios.variant
55
+ * (needs lecodes-android-sdk >= 1.3.0 — older releases ship only the full AAR).
56
+ * `signing`: keystore METADATA only — the store/key passwords live in android/
57
+ * keystore.properties (gitignored) or LECODES_ANDROID_* env vars, never in app.json. */
58
+ android?: {
59
+ applicationId?: string, versionCode?: number, sdk?: string, variant?: string,
60
+ signing?: { storeFile?: string, keyAlias?: string },
61
+ }
62
+ /** Desktop host (packages/desktop). `window` sizes are LOGICAL units — the host multiplies by
63
+ * the display scale. `entry` picks a per-target entry file (default: the detected project
64
+ * entry) — see docs/desktop-target-plan.md. */
65
+ desktop?: {
66
+ entry?: string,
67
+ window?: DesktopWindowConfig,
68
+ /** Which desktop host binary to run: "gl" (default) or "vulkan" (Windows only, host >= 1.4.0).
69
+ * A BUILD/DEV-TIME host selection, not a runtime switch — the two are separate binaries with
70
+ * their own shaders and different features (the Vulkan one has no 2D engine and no native-view
71
+ * plugins). Overridable per run with `--renderer` / LECODES_DESKTOP_RENDERER. */
72
+ renderer?: "gl" | "vulkan",
73
+ /** 3D buffer scale vs the window (0.25–1, host ≥1.4.0). The UI always renders at native
74
+ * pixels; only the 3D scene is rendered smaller and upscaled by the compositor. Multiplies
75
+ * the scene's own `renderScale` (SceneOptions). Headless render/test ignore it. */
76
+ renderScale?: number,
77
+ /** 3D buffer cap in px, e.g. [1920, 1080]: on a bigger window/monitor the 3D renders at most
78
+ * this size (aspect kept) — the per-machine answer to "fullscreen on a 4K display is slow". */
79
+ maxRenderSize?: [number, number],
80
+ /** The macOS .app build (`lecodes app build desktop` on a Mac) — identity + signing +
81
+ * notarization. See appDesktopMac.ts for the whole story. */
82
+ macos?: MacDesktopConfig,
83
+ }
84
+ }
85
+
86
+ /** app.json `desktop.macos`. Only NAMES live here (identity, keychain profile) — the certificate
87
+ * and the notarization password stay in the keychain. */
88
+ export interface MacDesktopConfig {
89
+ /** CFBundleIdentifier (default codes.le.app.<slug>). Reverse-DNS, stable across releases. */
90
+ bundleId?: string
91
+ /** CFBundleVersion (default 1). Bump per upload; the top-level `version` is the marketing one. */
92
+ buildNumber?: number
93
+ /** LSApplicationCategoryType, e.g. "public.app-category.games". */
94
+ category?: string
95
+ /** `identity`: a "Developer ID Application: Name (TEAM)" codesign identity, or "adhoc" (local
96
+ * only — refused on every other Mac). Absent/"auto": the keychain's Developer ID cert, else
97
+ * ad-hoc. Overridden by --sign / LECODES_MACOS_SIGN_ID. */
98
+ signing?: { identity?: string }
99
+ /** `profile`: a `xcrun notarytool store-credentials <name>` keychain profile. With it the build
100
+ * is notarized + stapled (needs a Developer ID identity). Overridden by --notarize /
101
+ * LECODES_NOTARY_PROFILE; --no-notarize skips for a local build. */
102
+ notarize?: { profile?: string }
103
+ }
104
+
105
+ export interface DesktopWindowConfig {
106
+ /** Initial client size, logical units (host default: 1280×720). */
107
+ width?: number
108
+ height?: number
109
+ minWidth?: number
110
+ minHeight?: number
111
+ /** false → fixed-size window (default: resizable). */
112
+ resizable?: boolean
113
+ /** Window title (default: the top-level `name`). */
114
+ title?: string
115
+ /** Borderless fullscreen on the primary monitor (kiosks). `width`/`height` stay the
116
+ * design/dev frame; headless render/test always run windowed (the render contract). */
117
+ fullscreen?: boolean
118
+ /** Display scale (host ≥1.3.0). "system" (default): the OS display scale. A number (0.5–4):
119
+ * a fixed density — the window opens at `width×scale` physical px. "fit": derived at launch
120
+ * from the design frame vs the monitor (fullscreen) / work area, so a 1080p-designed app
121
+ * runs density-2 on a 4K display and scales DOWN on smaller screens. Headless render/test
122
+ * always force scale 1 (the render contract). */
123
+ scale?: number | "fit" | "system"
124
+ }
125
+
126
+ /** app.json wherever a command needs the project-level desktop/window config. Tolerant of a
127
+ * missing or broken file — standalone projects (no app.json) must keep working everywhere
128
+ * this is consulted. */
129
+ export const readAppConfigAt = (root: string): AppConfig | null =>
130
+ readJson<AppConfig>(join(root, "app.json"))
131
+
132
+ /** The desktop host's own built-in fallback, mirrored where the CLI must fill a missing side. */
133
+ export const DESKTOP_DEFAULT_WINDOW = { width: 1280, height: 720 }
134
+
135
+ /**
136
+ * The renderer for this run: `--renderer` > LECODES_DESKTOP_RENDERER > app.json `desktop.renderer`
137
+ * > "gl". Resolve it ONCE per command and pass the result to both `resolveDesktopExe` and the
138
+ * shader staging — two independent reads of the config is how a build ends up with a Vulkan exe
139
+ * next to an OpenGL .filamat, which fails at the first material with a Filament abort.
140
+ */
141
+ export const resolveDesktopRenderer = (root: string, args?: Args): "gl" | "vulkan" => {
142
+ const raw = (args ? flagStr(args, "renderer") : undefined)
143
+ ?? process.env.LECODES_DESKTOP_RENDERER
144
+ ?? readAppConfigAt(root)?.desktop?.renderer
145
+ if (raw === undefined) return "gl"
146
+ const value = String(raw).toLowerCase()
147
+ if (value === "gl" || value === "opengl") return "gl"
148
+ if (value === "vulkan" || value === "vk") {
149
+ // The macOS host is Metal, full stop. A project tuned for the Windows Vulkan host must still
150
+ // build/run on a Mac, so the setting is a no-op here rather than an error.
151
+ if (process.platform === "darwin") { note("desktop.renderer \"vulkan\" is Windows-only — the macOS host renders with Metal."); return "gl" }
152
+ return "vulkan"
153
+ }
154
+ throw new CliError(`Unknown desktop renderer "${raw}". Use "gl" or "vulkan".`)
155
+ }
156
+
157
+ /** Project-level default viewport for HEADLESS render/test: the desktop window when the project
158
+ * declares desktop as its ONLY native target (a desktop-only project gets desktop-sized frames
159
+ * and flow tests for free). Mixed or mobile projects keep the phone default — headless frames
160
+ * exercise the phone UI there. */
161
+ export const desktopDefaultViewport = (root: string): { width: number, height: number } | null => {
162
+ const cfg = readAppConfigAt(root)
163
+ if (!cfg?.desktop || cfg.ios || cfg.android) return null
164
+ return {
165
+ width: cfg.desktop.window?.width ?? DESKTOP_DEFAULT_WINDOW.width,
166
+ height: cfg.desktop.window?.height ?? DESKTOP_DEFAULT_WINDOW.height,
167
+ }
168
+ }
169
+
170
+ /** A SwiftPM dependency a plugin's iOS target links (manifest ios.packages). */
171
+ export interface IosPackage {
172
+ url: string
173
+ /** Pinned release — the normal case (a plugin's sources compile against one API). */
174
+ exact?: string
175
+ /** Minimum release, SwiftPM picks the newest compatible major. Exclusive with `exact`. */
176
+ from?: string
177
+ /** Library products of that package the plugin target imports. */
178
+ products: string[]
179
+ }
180
+
181
+ /** SwiftPM's package identity for a url: the last path segment without `.git`. */
182
+ export const swiftPackageName = (url: string): string =>
183
+ (url.replace(/\/+$/, "").split("/").pop() ?? url).replace(/\.git$/, "")
184
+
185
+ export interface PluginManifest {
186
+ id: string
187
+ version?: string
188
+ provides?: { views?: string[], services?: string[] }
189
+ ios?: {
190
+ sources?: string, module?: string, register?: string, minSdk?: string,
191
+ infoPlist?: Record<string, string>,
192
+ /** Entitlements the plugin's capability needs (plist-typed JSON values, arrays allowed —
193
+ * e.g. push's `{"aps-environment": "development"}`). Merged into App/App.entitlements
194
+ * by sync; the relay signs sends for Letary-published bundle ids with the shared team
195
+ * key, and the register route fail-fasts for anything outside that namespace. */
196
+ entitlements?: Record<string, unknown>,
197
+ /** Swift packages the vendored target links (e.g. the map plugin's maplibre-native). Each
198
+ * becomes a `.package(url:…)` in the generated LeCodesRuntime/Package.swift (exact or
199
+ * from — one of the two) and its `products` become `.product(name:package:)` deps of the
200
+ * plugin target. The package name is the URL's last path segment minus `.git`, as SwiftPM
201
+ * derives it. Two plugins naming the same url must agree on the version. */
202
+ packages?: IosPackage[],
203
+ } | null
204
+ android?: {
205
+ /** Dir vendored into the shell (default "android") — a Gradle-module-shaped tree minus
206
+ * build.gradle (sync generates that): src/main/AndroidManifest.xml carries the plugin's
207
+ * permissions/services (AGP's manifest merger is the infoPlist analog), libs/*.aar become
208
+ * local file deps, consumer-rules.pro becomes the module's consumer ProGuard rules. */
209
+ sources?: string,
210
+ /** Gradle module name (kebab-case) → :plugins:<module> in the shell. */
211
+ module?: string,
212
+ /** Fully-qualified Kotlin object with `fun register(engine: LecodesEngine, context: Context)`;
213
+ * its package doubles as the generated module's AGP namespace. */
214
+ register?: string,
215
+ /** Minimum lecodes-android-sdk release (NOT Android's minSdkVersion — the OS floor comes
216
+ * from the SDK itself). Sync hard-errors below it, like ios.minSdk. */
217
+ minSdk?: string,
218
+ /** Maven coordinates for the generated build.gradle. A "platform:" prefix maps to
219
+ * implementation(platform(…)) — BoMs; versionless coords are only valid under one. */
220
+ dependencies?: string[],
221
+ /** App-level Gradle plugin ids the shell must apply (e.g. com.google.gms.google-services).
222
+ * Bare ids resolve through the CLI's known-version table; "id:version" pins explicitly. */
223
+ gradlePlugins?: string[],
224
+ } | null
225
+ /** Desktop host (packages/desktop): a PREBUILT shared library per OS/arch behind the C ABI in
226
+ * `packages/desktop/plugin-sdk/lecodes_plugin.h` — no host rebuild, no source vendoring.
227
+ * `library` keys are `${process.platform}-${process.arch}` ("win32-x64", "linux-x64",
228
+ * "linux-arm64"), values plugin-dir-relative paths. `lecodes desktop run` / `render --desktop`
229
+ * forward the library's dir to the host (CREATOR_PLUGIN_DIRS); `app build desktop` stages the
230
+ * file into `<build>/plugins/`. A plugin without a build for the current target is skipped. */
231
+ desktop?: {
232
+ library?: Record<string, string>,
233
+ } | null
234
+ }
235
+
236
+ export interface LockEntry { repo: string, tag: string, commit: string, version: string }
237
+
238
+ export const readJson = <T>(path: string): T | null => {
239
+ if (!existsSync(path)) return null
240
+ // BOM-tolerant: Notepad and PowerShell write UTF-8 with a BOM, and app.json is hand-edited.
241
+ try { return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")) as T } catch {
242
+ throw new CliError(`Invalid JSON in ${path}`)
243
+ }
244
+ }
245
+
246
+ export const writeJson = (path: string, value: unknown) =>
247
+ writeFileSync(path, JSON.stringify(value, null, 2) + "\n")
248
+
249
+ export const projectRoot = (): string => {
250
+ try { return findProjectRoot(process.cwd()) } catch { return process.cwd() }
251
+ }
252
+
253
+ export const git = (cwd: string, ...args: string[]): string =>
254
+ execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
255
+
256
+ export const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "app"
257
+
258
+ /** Run a tool, capturing output; on failure surface the tail (xcodebuild/gradle logs are huge). */
259
+ export const runTool = (cwd: string, cmd: string, args: string[]): string => {
260
+ try {
261
+ return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 128 * 1024 * 1024 })
262
+ } catch (e) {
263
+ const err = e as { stdout?: string, stderr?: string }
264
+ const tail = ((err.stdout ?? "") + "\n" + (err.stderr ?? "")).split("\n").filter(l => l.trim()).slice(-25).join("\n")
265
+ throw new CliError(`${cmd} ${args[0] ?? ""} failed:\n${tail}`)
266
+ }
267
+ }
268
+
269
+ /** Add a shell dir (ios/, android/) to .lecodesignore — shells are never pushed to LeCodes. */
270
+ export const ensureShellIgnored = (root: string, dir: string) => {
271
+ const ignorePath = join(root, ".lecodesignore")
272
+ const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : DEFAULT_IGNORE_FILE
273
+ if (new RegExp(`^${dir}\\/$`, "m").test(ignore)) return
274
+ const comment = ignore.includes("# Native app shells")
275
+ ? ""
276
+ : "# Native app shells — generated by `lecodes app init`, never pushed to LeCodes.\n"
277
+ writeFileSync(ignorePath, ignore.replace(/\n*$/, "\n") + "\n" + comment + `${dir}/\n`)
278
+ log(`${c.green("updated")} .lecodesignore (+ ${dir}/)`)
279
+ }
280
+
281
+ /* ----------------------------------- SDK variants ----------------------------------- */
282
+
283
+ // Both native SDKs ship the same variant matrix (full / 3d / 2d / core), so the detection and
284
+ // resolution logic is shared between the iOS (app.ts) and Android (appAndroid.ts) syncs.
285
+ export const SDK_VARIANTS = ["core", "2d", "3d", "full"] as const
286
+ type SdkVariantShared = (typeof SDK_VARIANTS)[number]
287
+
288
+ /** Engine signals from a compiled bundle's header — the same lines buildHeader emits.
289
+ * No `// gl: disable` line → the 3D engine is used (eagerly, or lazily via `gl: defer`);
290
+ * `// 2d: enable` → the 2D engine is used. */
291
+ export const detectEngines = (bundleJs: string): { gl: boolean, g2: boolean } => {
292
+ const lines: string[] = []
293
+ for (const line of bundleJs.split("\n")) {
294
+ if (!line.startsWith("//")) break
295
+ lines.push(line)
296
+ }
297
+ const header = lines.join("\n")
298
+ return { gl: !/^\/\/ gl: disable$/m.test(header), g2: /^\/\/ 2d: enable$/m.test(header) }
299
+ }
300
+
301
+ /**
302
+ * Refuse a Vulkan desktop build for a project that uses the 2D engine. The Vulkan host is built with
303
+ * CREATOR_PKG_2D=OFF (creator-2d is sokol-on-GL, with no Vulkan twin), so those scenes don't render
304
+ * — a blank window at runtime with nothing in the log to explain it. Fail at build time instead,
305
+ * where the fix ("use the default renderer") is one line away.
306
+ *
307
+ * Detection is the same `// 2d: enable` bundle header `lecodes app sync` picks its SDK variant from,
308
+ * so it can't drift from what the engine actually needs.
309
+ */
310
+ export const assertRendererSupportsBundle = (renderer: "gl" | "vulkan", bundleJs: string): void => {
311
+ if (renderer !== "vulkan") return
312
+ if (!detectEngines(bundleJs).g2) return
313
+ throw new CliError(
314
+ "This project uses the 2D engine, which the Vulkan desktop host doesn't include " +
315
+ "(creator-2d renders through OpenGL and has no Vulkan path yet).\n" +
316
+ " Build with the default renderer: drop `desktop.renderer` from app.json, or pass `--renderer gl`.",
317
+ )
318
+ }
319
+
320
+ /** The SDK product for a shell: the smallest variant covering the engines the compiled bundle
321
+ * actually uses, unless <configKey> (ios.variant / android.variant) forces one — then warn when
322
+ * the forced product can't run the project (missing engine = those scenes silently no-op on
323
+ * device). */
324
+ export const resolveVariant = (requested: string, bundleJs: string, configKey = "ios.variant"): { variant: SdkVariantShared, detected: SdkVariantShared } => {
325
+ const uses = detectEngines(bundleJs)
326
+ const detected: SdkVariantShared = uses.gl && uses.g2 ? "full" : uses.gl ? "3d" : uses.g2 ? "2d" : "core"
327
+ if (requested === "auto") return { variant: detected, detected }
328
+ const variant = requested as SdkVariantShared
329
+ const missing = [
330
+ uses.gl && variant !== "3d" && variant !== "full" ? "3D" : null,
331
+ uses.g2 && variant !== "2d" && variant !== "full" ? "2D" : null,
332
+ ].filter(Boolean)
333
+ if (missing.length > 0) {
334
+ warn(`project uses the ${missing.join(" + ")} engine but ${configKey} forces "${variant}" — those scenes won't run on device; remove ${configKey} to auto-select "${detected}".`)
335
+ }
336
+ return { variant, detected }
337
+ }
338
+
339
+ /* ----------------------------------- SDK version ------------------------------------ */
340
+
341
+ export const VERSION_RE = /^\d+\.\d+\.\d+$/
342
+
343
+ /** Numeric semver-ish compare (only x.y.z tags are published). */
344
+ export const cmpVersion = (a: string, b: string): number => {
345
+ const pa = a.split(".").map(Number)
346
+ const pb = b.split(".").map(Number)
347
+ for (let i = 0; i < 3; i++) {
348
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0)
349
+ }
350
+ return 0
351
+ }
352
+
353
+ /** The newest published release of an SDK dist repo (prereleases excluded). Network — throws. */
354
+ export const fetchLatestSdkVersion = async (repo: string): Promise<string> => {
355
+ const url = `https://api.github.com/repos/${repo}/releases/latest`
356
+ const res = await fetch(url, { headers: { "accept": "application/vnd.github+json", "user-agent": "lecodes-cli" } })
357
+ if (!res.ok) throw new CliError(`Could not read the latest ${repo} release (HTTP ${res.status}) — ${url}`)
358
+ const tag = ((await res.json()) as { tag_name?: string }).tag_name?.replace(/^v/, "") ?? ""
359
+ if (!VERSION_RE.test(tag)) throw new CliError(`${repo} latest release has no x.y.z tag (got "${tag}")`)
360
+ return tag
361
+ }
362
+
363
+ /** Resolve an app.json sdk value ("latest" or an exact release) to the concrete release the
364
+ * shell links; `fallback` is what "latest" degrades to when the network is unavailable. */
365
+ export const resolveSdkVersion = async (
366
+ pin: string, fallback: string | null,
367
+ opts: { repo: string, minVersion: string, configKey: string },
368
+ ): Promise<string> => {
369
+ if (pin !== "latest") {
370
+ if (!VERSION_RE.test(pin)) {
371
+ throw new CliError(`app.json ${opts.configKey} must be "latest" or an exact x.y.z release (got "${pin}").`)
372
+ }
373
+ if (cmpVersion(pin, opts.minVersion) < 0) {
374
+ throw new CliError(`app.json ${opts.configKey} ${pin} is older than the ${opts.minVersion} these generated shells need.`)
375
+ }
376
+ return pin
377
+ }
378
+ try {
379
+ return await fetchLatestSdkVersion(opts.repo)
380
+ } catch (e) {
381
+ const kept = fallback ?? opts.minVersion
382
+ warn(`Could not resolve the latest SDK (${(e as Error).message}) — staying on ${kept}.`)
383
+ return kept
384
+ }
385
+ }
386
+
387
+ /* ---------------------------------- plugin vendoring ---------------------------------- */
388
+
389
+ export interface ResolvedPlugin {
390
+ id: string
391
+ dir: string // directory containing lecodes-plugin.json
392
+ manifest: PluginManifest
393
+ lock: LockEntry
394
+ }
395
+
396
+ /** Clone-or-update the plugin repo cache and check out the wanted commit (detached).
397
+ * `LECODES_PLUGINS_DIR` overrides with a local working tree — the plugin-development loop. */
398
+ export const ensureRepo = (repo: string, wantTag: string | null, lock: LockEntry | undefined): { dir: string, tag: string, commit: string } => {
399
+ const override = process.env.LECODES_PLUGINS_DIR
400
+ if (override) {
401
+ note(`Using local plugin checkout ${override} (LECODES_PLUGINS_DIR)`)
402
+ return { dir: resolve(override), tag: "local", commit: "local" }
403
+ }
404
+
405
+ const cache = join(homedir(), ".lecodes", "plugins", repo.replace("/", "__"))
406
+ const url = `https://github.com/${repo}.git`
407
+ if (!existsSync(join(cache, ".git"))) {
408
+ mkdirSync(dirname(cache), { recursive: true })
409
+ note(`Cloning ${url}…`)
410
+ git(dirname(cache), "clone", "--quiet", url, cache)
411
+ }
412
+
413
+ // A lock pin wins; else an explicit @tag; else the latest v* tag (fetched fresh).
414
+ let tag = lock?.tag ?? wantTag
415
+ let commit = lock?.commit
416
+ if (commit) {
417
+ try { git(cache, "cat-file", "-e", `${commit}^{commit}`) } catch {
418
+ git(cache, "fetch", "--quiet", "--tags", "origin")
419
+ }
420
+ } else {
421
+ git(cache, "fetch", "--quiet", "--tags", "origin")
422
+ if (!tag) {
423
+ tag = git(cache, "tag", "--list", "v*", "--sort=-version:refname").split("\n")[0] ?? ""
424
+ if (!tag) throw new CliError(`${repo} has no v* version tags to install from`)
425
+ }
426
+ commit = git(cache, "rev-list", "-n", "1", tag)
427
+ }
428
+ git(cache, "checkout", "--quiet", "--detach", commit)
429
+ return { dir: cache, tag: tag ?? "unknown", commit }
430
+ }
431
+
432
+ /** Resolve one app.json plugin entry to vendorable sources + manifest + lock pin. The platform
433
+ * being synced must be supported (its manifest branch non-null) — a hard error otherwise. */
434
+ export type PluginPlatform = "ios" | "android" | "desktop"
435
+ const PLUGIN_PLATFORM_LABEL: Record<PluginPlatform, string> = { ios: "iOS", android: "Android", desktop: "desktop" }
436
+
437
+ export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform): ResolvedPlugin
438
+ export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts: { optional: true }): ResolvedPlugin | null
439
+ export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts?: { optional?: boolean }): ResolvedPlugin | null {
440
+ let repo: string
441
+ let subdir: string
442
+ let wantTag: string | null = null
443
+
444
+ if (spec.startsWith("github:")) {
445
+ const [repoPart, tagPart] = spec.slice("github:".length).split("@")
446
+ repo = repoPart
447
+ wantTag = tagPart ?? null
448
+ subdir = "" // third-party: manifest at the repo root
449
+ } else {
450
+ repo = FIRST_PARTY_PLUGINS[id]
451
+ if (!repo) {
452
+ throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use a github:owner/repo spec.`)
453
+ }
454
+ subdir = `plugins/${id}`
455
+ }
456
+
457
+ const checkout = ensureRepo(repo, wantTag, lock?.repo === repo ? lock : undefined)
458
+ const dir = subdir ? join(checkout.dir, subdir) : checkout.dir
459
+ const manifest = readJson<PluginManifest>(join(dir, "lecodes-plugin.json"))
460
+ if (!manifest) throw new CliError(`Plugin "${id}": no lecodes-plugin.json in ${dir}`)
461
+ if (manifest.id !== id) throw new CliError(`Plugin "${id}": manifest declares id "${manifest.id}"`)
462
+ if (!manifest[platform]) {
463
+ if (opts?.optional) return null
464
+ throw new CliError(`Plugin "${id}" does not support ${PLUGIN_PLATFORM_LABEL[platform]} (manifest "${platform}" is null)`)
465
+ }
466
+
467
+ // Namespacing: third-party channel names must be vendor-prefixed ("acme.thing").
468
+ if (spec.startsWith("github:")) {
469
+ const names = [...(manifest.provides?.views ?? []), ...(manifest.provides?.services ?? [])]
470
+ for (const name of names) {
471
+ if (!name.includes(".")) warn(`Plugin "${id}" provides bare channel name "${name}" — third-party names should be vendor-prefixed (e.g. "acme.${name}").`)
472
+ }
473
+ }
474
+
475
+ return {
476
+ id, dir, manifest,
477
+ lock: { repo, tag: checkout.tag, commit: checkout.commit, version: manifest.version ?? "unknown" },
478
+ }
479
+ }
480
+
481
+ /** Newest v* tag of a plugin repo, via the shared plugin cache. Null when it can't be read
482
+ * (no network, no tags, local override) — `app update` reports it and moves on. */
483
+ export const latestPluginTag = (repo: string): string | null => {
484
+ try { return ensureRepo(repo, null, undefined).tag } catch { return null }
485
+ }
486
+
487
+ /* ---------------------------------- desktop native plugins ---------------------------------- */
488
+
489
+ /** The desktop host's plugin library target key (manifest `desktop.library` keys). */
490
+ export const desktopPluginTarget = (): string => `${process.platform}-${process.arch}`
491
+
492
+ const isDesktopPluginLibrary = (name: string): boolean => {
493
+ const n = name.toLowerCase()
494
+ return process.platform === "win32" ? n.endsWith(".dll") : n.endsWith(".so")
495
+ }
496
+
497
+ /** Native plugin libraries for THIS project on THIS machine (packages/desktop/plugin-sdk/
498
+ * lecodes_plugin.h — runtime-loaded .dll/.so, no host rebuild):
499
+ * - every library in the project's own `plugins/` dir — the zero-manifest dev loop (build a
500
+ * DLL, drop it there, `lecodes desktop run`);
501
+ * - the `desktop.library[target]` of every app.json plugin whose manifest declares one
502
+ * (mobile-only plugins are skipped, not errors).
503
+ * `dirs` is what a run forwards to the host as CREATOR_PLUGIN_DIRS (the host scans them);
504
+ * `files` is what `app build desktop` copies into `<build>/plugins/`. `strict` turns a broken
505
+ * declaration into an error (builds); runs warn and go on without that plugin. */
506
+ export const desktopPluginLibraries = (root: string, opts: { strict?: boolean } = {}): { dirs: string[], files: string[] } => {
507
+ const dirs: string[] = []
508
+ const files: string[] = []
509
+ const local = join(root, "plugins")
510
+ if (existsSync(local) && statSync(local).isDirectory()) {
511
+ dirs.push(local)
512
+ for (const f of readdirSync(local)) if (isDesktopPluginLibrary(f)) files.push(join(local, f))
513
+ }
514
+ const config = readJson<AppConfig>(join(root, "app.json"))
515
+ const specs = Object.entries(config?.plugins ?? {})
516
+ if (specs.length === 0) return { dirs, files }
517
+ const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
518
+ const target = desktopPluginTarget()
519
+ for (const [id, spec] of specs) {
520
+ let plugin: ResolvedPlugin | null
521
+ try {
522
+ plugin = resolvePlugin(id, spec, lock[id], "desktop", { optional: true })
523
+ } catch (e) {
524
+ if (opts.strict) throw e
525
+ warn(`Plugin "${id}": ${(e as Error).message} — skipped for this desktop run`)
526
+ continue
527
+ }
528
+ const rel = plugin?.manifest.desktop?.library?.[target]
529
+ if (!plugin || !rel) continue // mobile-only, or no build for this OS/arch
530
+ const abs = resolve(plugin.dir, rel)
531
+ if (!existsSync(abs)) {
532
+ const msg = `Plugin "${id}": desktop library ${abs} is missing (manifest desktop.library["${target}"])`
533
+ if (opts.strict) throw new CliError(msg)
534
+ warn(`${msg} — skipped for this desktop run`)
535
+ continue
536
+ }
537
+ files.push(abs)
538
+ const dir = dirname(abs)
539
+ if (!dirs.includes(dir)) dirs.push(dir)
540
+ }
541
+ return { dirs, files }
542
+ }