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
@@ -0,0 +1,414 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { dirname, resolve } from "node:path"
3
+ import { compileHeadlessBundle } from "../compile/headlessBundle"
4
+ import { screenTargetFromArgs } from "../compile/screenEntry"
5
+ import { findProjectRoot } from "../manifest"
6
+ import { buildBundle } from "./compile"
7
+ import { desktopDefaultViewport, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
8
+ import { runDesktopRender } from "../desktopRenderer"
9
+ import { evaluateDesktopRun, translateScenario } from "../desktopScript"
10
+ import { CliError, c, flagStr, flagBool, isNullSink, logErr, note, warnErr, type Args } from "../util"
11
+
12
+ /*
13
+ * `lecodes render` — compile the local project and render it headless. By default it emits a
14
+ * semantic JSON description (stdout or -o/--out); `--png [file]` also rasterizes a PNG. Rendering
15
+ * runs locally via the optional `lecodes-renderer` package (canvas-ui + the layout WASM, and the
16
+ * GPU-free creator-2d WASM for 2D scenes); PNG also needs @napi-rs/canvas. UI and 2D (creator-2d)
17
+ * scenes are both rendered — the 2D scene composites UNDER the UI. 3D (Filament) isn't rasterized,
18
+ * but the scene graph is recorded and serialized into the JSON output as `scene3d` (hierarchy,
19
+ * transforms, meshes, materials, lights, GLB structure + animations).
20
+ * --time advances the 2D simulation (physics/animation) to that instant.
21
+ *
22
+ * `lecodes render <file>` renders ONE screen module instead of the launched app: a file that
23
+ * default-exports a `UIScreen` (or a `(state) => UIScreen` function — pick the branch with
24
+ * `--state`), which is how every `lecodes design` screen is written. It compiles as part of the
25
+ * project (its imports and asset() files resolve normally), and a design screen picks up its board
26
+ * frame from the design folder's meta.json unless --width/--height say otherwise. A bare id
27
+ * ("login") is looked up in design/screens/ (--dir overrides the folder).
28
+ *
29
+ * `--script <file>` switches to an interactive run: the file lists steps (tap / type / scroll /
30
+ * wait / expect / screenshot / json) executed against a live session — see the scenario schema in
31
+ * lecodes-renderer (headless/scenario.ts). Step outputs land under --out-dir (default: the CWD);
32
+ * the run stops at the first failed step and dumps the failing frame next to them.
33
+ *
34
+ * Works in both server-backed projects (login) and local `lecodes init` projects (no login) — see
35
+ * compile/headlessBundle.ts. Resource assets (asset('./x.png')) resolve from the local working tree
36
+ * by default; use --remote-assets (or an explicit --public-url) to load them from the server
37
+ * instead, which then needs --wait-network.
38
+ *
39
+ * Snapshot timing flags:
40
+ * --settle <ms> max wait for promises/timers/fetch/image decode (ends early when idle)
41
+ * --wait-network let data fetches hit the real network (off by default → empty responses)
42
+ * --remote-assets load asset() resources from the server URL instead of the local disk
43
+ * --time <ms> render at this animation time (0 = the initial frame-0 state)
44
+ * --no-after-animations keep frame 0 instead of advancing animations to their resting state
45
+ * --timeout <ms> hard cap on the whole render before aborting (default 15000; a script
46
+ * run defaults to 15000 + 3000 per step)
47
+ * --safe-area t,r,b,l override the safe-area insets (px) the `safe-*` keywords resolve to
48
+ * --device <preset> safe-area preset: none | iphone | android (default: orientation-based).
49
+ * Presets also carry the edge KIND: android's status/nav bars are exact
50
+ * heights (comfort-* adds its knob past them); iphone insets are
51
+ * clearance zones (comfort-* floors). --safe-area alone = all clearance.
52
+ * --logs forward the app's console.log/error (to stderr) for logic debugging
53
+ * --clip <name|x,y,w,h> (--png) crop to a named node's layout rect or an explicit logical-px
54
+ * rect — a component / region render instead of the whole viewport
55
+ */
56
+
57
+ // Approximate device safe-area presets, portrait: insets [top, right, bottom, left] (px) + which
58
+ // edges are exact-height system BARS. `comfort-*` ADDS its knob past a bar edge (content at the
59
+ // inset touches the chrome) and floors (max) a clearance edge (breathing room built in).
60
+ const DEVICE_SAFE_AREA: Record<string, { insets: [number, number, number, number]; bars: ("top" | "right" | "bottom" | "left")[] }> = {
61
+ none: { insets: [0, 0, 0, 0], bars: [] },
62
+ iphone: { insets: [59, 0, 34, 0], bars: [] }, // Dynamic Island + home indicator (iPhone 14 Pro+) — clearance built in
63
+ android: { insets: [24, 0, 48, 0], bars: ["top", "bottom"] }, // status bar + 3-button navigation bar (matches the design board's shell)
64
+ }
65
+
66
+ type SafeAreaChoice = { safeArea: [number, number, number, number]; safeAreaBars: ("top" | "right" | "bottom" | "left")[] }
67
+
68
+ /** Resolve an explicit `t,r,b,l` string (wins; clearance edges) or a device preset, or undefined.
69
+ * Shared by the flag form (`--safe-area` / `--device`) and a script file's envelope fields. */
70
+ export const resolveSafeAreaChoice = (explicit: string | undefined, device: string | undefined): SafeAreaChoice | undefined => {
71
+ if (explicit) {
72
+ const n = explicit.split(",").map((s) => Number(s.trim()))
73
+ if (n.length === 4 && n.every((v) => Number.isFinite(v))) return { safeArea: n as [number, number, number, number], safeAreaBars: [] }
74
+ throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`)
75
+ }
76
+ if (device) {
77
+ const preset = DEVICE_SAFE_AREA[device]
78
+ if (!preset) throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`)
79
+ return { safeArea: preset.insets, safeAreaBars: preset.bars }
80
+ }
81
+ return undefined
82
+ }
83
+
84
+ /** Import the optional renderer package or fail with the install hint. */
85
+ export const requireRenderer = async (): Promise<typeof import("lecodes-renderer/headless")> => {
86
+ try {
87
+ return await import("lecodes-renderer/headless")
88
+ } catch {
89
+ throw new CliError("The renderer isn't installed. Run: npm install -g lecodes-renderer")
90
+ }
91
+ }
92
+
93
+ /** A file-writing ScenarioIO rooted at `dir` (creates parent folders as needed). */
94
+ export const fileScenarioIO = (dir: string) => ({
95
+ write: (relPath: string, data: string | Uint8Array) => {
96
+ const abs = resolve(dir, relPath)
97
+ mkdirSync(dirname(abs), { recursive: true })
98
+ writeFileSync(abs, data)
99
+ },
100
+ })
101
+
102
+ /** The parsed envelope of a scenario file: viewport/timing fields + the raw steps. `fixtures` is
103
+ * a canned-response map (URL pattern → { status?, json?, text? }) or a path to a JSON file of one. */
104
+ type ScenarioFile = {
105
+ name?: string
106
+ width?: number
107
+ height?: number
108
+ settle?: number
109
+ device?: string
110
+ safeArea?: string
111
+ fixtures?: Record<string, unknown> | string
112
+ steps: unknown
113
+ }
114
+
115
+ /** Resolve a fixtures value — an inline map, or a string path to a JSON file holding one —
116
+ * relative to `baseDir`. Throws a CliError on bad shapes (with the offending pattern). */
117
+ export const resolveFixtures = (
118
+ value: Record<string, unknown> | string | undefined,
119
+ baseDir: string,
120
+ ): import("lecodes-renderer/headless").Fixtures | undefined => {
121
+ if (value === undefined) return undefined
122
+ let map: unknown = value
123
+ if (typeof value === "string") {
124
+ const abs = resolve(baseDir, value)
125
+ try {
126
+ map = JSON.parse(readFileSync(abs, "utf8"))
127
+ } catch (e) {
128
+ throw new CliError(`Can't read fixtures "${value}": ${e instanceof Error ? e.message : String(e)}`)
129
+ }
130
+ }
131
+ if (typeof map !== "object" || map === null || Array.isArray(map)) {
132
+ throw new CliError(`Fixtures must be an object mapping URL patterns to { status?, json?, text? }.`)
133
+ }
134
+ return map as import("lecodes-renderer/headless").Fixtures
135
+ }
136
+
137
+ const readScenarioFile = (path: string): ScenarioFile => {
138
+ let raw: unknown
139
+ try {
140
+ raw = JSON.parse(readFileSync(resolve(path), "utf8"))
141
+ } catch (e) {
142
+ throw new CliError(`Can't read scenario "${path}": ${e instanceof Error ? e.message : String(e)}`)
143
+ }
144
+ if (Array.isArray(raw)) return { steps: raw } // a bare step array is a valid scenario file
145
+ if (typeof raw !== "object" || raw === null) throw new CliError(`Scenario "${path}" must be a JSON object with "steps" (or a bare step array).`)
146
+ const file = raw as ScenarioFile
147
+ if (file.steps === undefined) throw new CliError(`Scenario "${path}" has no "steps".`)
148
+ return file
149
+ }
150
+
151
+ /** `--clip <name|x,y,w,h>`: a node name (its layout rect) or an explicit logical-px rect. */
152
+ const parseClip = (raw: string | undefined): string | { x: number, y: number, width: number, height: number } | undefined => {
153
+ if (raw === undefined) return undefined
154
+ const parts = raw.split(",").map((p) => p.trim())
155
+ if (parts.length === 4 && parts.every((p) => /^-?\d+(\.\d+)?$/.test(p))) {
156
+ const [x, y, width, height] = parts.map(Number)
157
+ if (width <= 0 || height <= 0) throw new CliError(`--clip: width and height must be positive (got "${raw}").`)
158
+ return { x, y, width, height }
159
+ }
160
+ if (parts.length === 1 && raw.trim() !== "") return raw.trim()
161
+ throw new CliError(`--clip: expected a node name or "x,y,width,height" (got "${raw}").`)
162
+ }
163
+
164
+ /* `--desktop`: render through the NATIVE desktop host instead of the headless renderer — the real
165
+ * tgfx UI + Filament 3D + creator-2d pipeline, composited exactly as desktop users see it. PNG
166
+ * only (the host has no semantic-JSON serializer), no lecodes-renderer / @napi-rs/canvas needed.
167
+ * The host binary downloads on first use (see desktopRenderer.ts); Windows + Linux for now. */
168
+ const renderDesktop = async (args: Args) => {
169
+ // A project with a desktop block renders at its configured window (this IS the desktop host);
170
+ // the phone-portrait literal stays only for projects that never declared one.
171
+ let root: string
172
+ try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() }
173
+ const win = readAppConfigAt(root)?.desktop?.window
174
+ // `render <file>`: one screen module rather than the app (see screenEntry.ts) — the native host
175
+ // runs the same bundle, so a design screen renders through the real tgfx UI here.
176
+ const screen = screenTargetFromArgs(root, args)
177
+ const scriptFlag = flagStr(args, "script")
178
+ const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null
179
+ const width = Number(flagStr(args, "width")) || scenario?.width || screen?.frame?.[0] || win?.width || 390
180
+ const height = Number(flagStr(args, "height")) || scenario?.height || screen?.frame?.[1] || win?.height || 844
181
+ const frames = Number(flagStr(args, "frames")) || 60
182
+ const pngFlag = flagStr(args, "png") ?? "screenshot.png"
183
+ const logs = flagBool(args, "logs")
184
+ // --fixed-dt <ms>: virtual app clock (CREATOR_FIXED_DT). Scripted runs default to 16 so a held
185
+ // key / drag / wait means the same game time on every machine; plain renders keep the wall clock
186
+ // (their contract is "frame N", unchanged) unless asked.
187
+ const fixedDtFlag = flagStr(args, "fixed-dt")
188
+ const fixedDtMs = fixedDtFlag !== undefined ? Number(fixedDtFlag) : scenario ? 16 : 0
189
+ if (!Number.isFinite(fixedDtMs) || fixedDtMs < 0 || fixedDtMs > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${fixedDtFlag}".`)
190
+
191
+ // Inspection camera: --camera x,y,z --look-at x,y,z [--fov N], or --view iso|top|front|side|back
192
+ // [--fov N] (auto-fitted to the scene's bounds). App input can't be trusted while it's on, so a
193
+ // scripted run sets it per step ({ "view": … } / { "camera": … }) instead of globally.
194
+ const { camera, view } = resolveInspectionCamera(args)
195
+ if ((camera || view) && scenario) throw new CliError("--camera/--view apply to a plain render; in a --script run use a { \"view\": \"iso\" } or { \"camera\": { \"at\", \"lookAt\" } } step before the screenshot.")
196
+
197
+ // One renderer decision: the host binary AND the staged .filamat variant (see appShared).
198
+ const desktopRenderer = resolveDesktopRenderer(root, args)
199
+ const { js, resources } = await buildBundle(args, undefined, {
200
+ localAssets: true, desktopRenderer,
201
+ extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
202
+ })
203
+ // The host writes PNGs but never creates folders — make every output dir up front.
204
+ mkdirSync(dirname(resolve(pngFlag)), { recursive: true })
205
+
206
+ if (scenario) {
207
+ // Scripted native run: translate the scenario into the host's command file, let the host drive
208
+ // it (it self-terminates), then read its markers back into per-step results.
209
+ const outDir = flagStr(args, "out-dir") ?? "."
210
+ const plan = translateScenario(scenario.steps, { outDir, headroomFrames: frames })
211
+ for (const st of plan.steps) if (st.screenshot) mkdirSync(dirname(st.screenshot), { recursive: true })
212
+ const timeoutMs = Number(flagStr(args, "timeout")) || (60000 + plan.steps.length * 5000)
213
+ note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (native)…`)
214
+ const { output } = await runDesktopRender(js, resources, {
215
+ renderer: desktopRenderer,
216
+ width, height, frames: plan.frameCap, outPng: pngFlag, logs, timeoutMs, script: plan.text, fixedDtMs,
217
+ })
218
+ const result = evaluateDesktopRun(plan, output)
219
+ if (!result.loaded) {
220
+ throw new CliError("This desktop host doesn't support scripted input (CREATOR_SCRIPT) — it ran the frames and ignored the steps. Update it: lecodes desktop update (or point LECODES_DESKTOP_EXE at a newer build).")
221
+ }
222
+ for (const st of result.steps) {
223
+ logErr(st.ok ? `${c.green("✓")} ${st.desc}` : `${c.red("✗")} ${st.desc}\n ${c.red(st.error ?? "failed")}`)
224
+ for (const sk of st.skipped) logErr(c.dim(` – skipped: ${sk}`))
225
+ }
226
+ const skippedCount = result.steps.reduce((n, s) => n + s.skipped.length, 0)
227
+ if (skippedCount > 0) logErr(c.dim(` ${skippedCount} semantic check${skippedCount === 1 ? "" : "s"} skipped — the native host has no UI tree; run the same scenario headless to verify them.`))
228
+ if (!result.ended) warnErr("The host stopped before the script finished (frame cap / crash) — later steps are reported as incomplete.")
229
+ logErr(result.ok ? c.green(`✓ ${result.steps.length}/${result.steps.length} steps passed (native)`) : c.red(`✗ ${result.steps.filter((s) => !s.ok).length} of ${result.steps.length} steps failed (native)`))
230
+ note(`Wrote ${pngFlag} (${width}x${height}, native render, final frame)`)
231
+ process.exit(result.ok ? 0 : 1)
232
+ }
233
+
234
+ const timeoutMs = Number(flagStr(args, "timeout")) || 60000
235
+ note(screen ? `Rendering ${screen.label} (native)…` : "Rendering (native)…")
236
+ await runDesktopRender(js, resources, {
237
+ renderer: desktopRenderer,
238
+ width, height, frames, outPng: pngFlag, logs, timeoutMs, fixedDtMs, camera, view,
239
+ })
240
+ note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
241
+ }
242
+
243
+ const VIEW_NAMES = ["iso", "top", "front", "side", "back"]
244
+ const parseVec3Flag = (name: string, raw: string): string => {
245
+ const parts = raw.split(",").map((p) => p.trim())
246
+ if (parts.length !== 3 || parts.some((p) => !/^-?\d+(\.\d+)?$/.test(p))) throw new CliError(`--${name} wants "x,y,z" (got "${raw}").`)
247
+ return parts.join(",")
248
+ }
249
+ /** `--camera x,y,z --look-at x,y,z [--fov N]` → CREATOR_CAMERA; `--view name [--fov N]` → CREATOR_VIEW. */
250
+ const resolveInspectionCamera = (args: Args): { camera?: string, view?: string } => {
251
+ const cam = flagStr(args, "camera"), look = flagStr(args, "look-at"), view = flagStr(args, "view"), fov = flagStr(args, "fov")
252
+ if (fov !== undefined && !(Number(fov) > 0 && Number(fov) < 180)) throw new CliError(`--fov wants degrees in (0, 180), got "${fov}".`)
253
+ const fovSuffix = fov !== undefined ? `;${Number(fov)}` : ""
254
+ if (cam !== undefined || look !== undefined) {
255
+ if (view !== undefined) throw new CliError("Use either --camera/--look-at or --view, not both.")
256
+ if (cam === undefined || look === undefined) throw new CliError("--camera and --look-at go together: --camera x,y,z --look-at x,y,z")
257
+ return { camera: `${parseVec3Flag("camera", cam)};${parseVec3Flag("look-at", look)}${fovSuffix}` }
258
+ }
259
+ if (view !== undefined) {
260
+ if (!VIEW_NAMES.includes(view)) throw new CliError(`--view wants one of ${VIEW_NAMES.join(", ")} (got "${view}").`)
261
+ return { view: `${view}${fovSuffix}` }
262
+ }
263
+ if (fov !== undefined) throw new CliError("--fov goes with --camera/--look-at or --view.")
264
+ return {}
265
+ }
266
+
267
+ export const render = async (args: Args) => {
268
+ if (flagBool(args, "desktop")) return renderDesktop(args)
269
+
270
+ const scriptFlag = flagStr(args, "script")
271
+ const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null
272
+
273
+ // `render <file>`: render that one screen module instead of the app's own entrypoint.
274
+ let projectRoot: string
275
+ try { projectRoot = findProjectRoot(process.cwd()) } catch { projectRoot = process.cwd() }
276
+ const screen = screenTargetFromArgs(projectRoot, args)
277
+
278
+ // Hard timeout so a pathological scene (stuck fetch, runaway loop) can't hang the CLI. Scripts get
279
+ // a per-step allowance. Unref'd so it never itself keeps the process alive once we're done.
280
+ const stepCount = scenario && Array.isArray(scenario.steps) ? scenario.steps.length : 0
281
+ const timeoutMs = Number(flagStr(args, "timeout")) || (15000 + stepCount * 3000)
282
+ const killer = setTimeout(() => {
283
+ warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`)
284
+ process.exit(1)
285
+ }, timeoutMs)
286
+ killer.unref?.()
287
+
288
+ const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args, { screen })
289
+ const renderer = await requireRenderer()
290
+
291
+ // Flags override the scenario file's envelope; both fall back to the project default (a
292
+ // desktop-only app.json renders desktop-sized), then the phone-portrait literals.
293
+ // A design screen carries its own board frame (meta.json), which beats the app-wide default.
294
+ const projectViewport = desktopDefaultViewport(root)
295
+ const width = Number(flagStr(args, "width")) || scenario?.width || screen?.frame?.[0] || projectViewport?.width || 390
296
+ const height = Number(flagStr(args, "height")) || scenario?.height || screen?.frame?.[1] || projectViewport?.height || 844
297
+
298
+ // Shared snapshot-timing options (see the header comment). `--logs` forwards the app's console to
299
+ // stderr (kept off stdout so it never corrupts the JSON/PNG result there).
300
+ const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined)
301
+ const timeStr = flagStr(args, "time")
302
+ const onConsole = flagBool(args, "logs")
303
+ ? (level: string, text: string) => logErr(`${c.dim(`[${level}]`)} ${text}`)
304
+ : undefined
305
+ // Fixtures: the scenario file's (inline map or a path), with a `--fixtures <file>` flag merged
306
+ // over it (flag patterns win on collision). Validated here so a typo is a clean CLI error.
307
+ const envelopeFixtures = resolveFixtures(scenario?.fixtures, scriptFlag !== undefined ? dirname(resolve(scriptFlag)) : ".")
308
+ const flagFixtures = resolveFixtures(flagStr(args, "fixtures"), ".")
309
+ const fixtures = envelopeFixtures || flagFixtures ? { ...envelopeFixtures, ...flagFixtures } : undefined
310
+ if (fixtures) {
311
+ try {
312
+ renderer.validateFixtures(fixtures)
313
+ } catch (e) {
314
+ throw new CliError(e instanceof Error ? e.message : String(e))
315
+ }
316
+ }
317
+
318
+ const timing = {
319
+ settleMs: settleStr !== undefined ? Number(settleStr) : undefined,
320
+ waitNetwork: flagBool(args, "wait-network"),
321
+ localAssets: useLocalAssets,
322
+ fixtures,
323
+ timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
324
+ afterAnimations: !flagBool(args, "no-after-animations"),
325
+ ...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? scenario?.safeArea, flagStr(args, "device") ?? scenario?.device),
326
+ onConsole,
327
+ }
328
+
329
+ // Clean process exit: clear the timeout guard, flush the given output, then exit. Without this the
330
+ // process could linger (e.g. on stdout pipe back-pressure) even though the render is done.
331
+ const done = (flush: (cb: () => void) => void, code = 0) => flush(() => { clearTimeout(killer); process.exit(code) })
332
+
333
+ // --script: drive the compiled app through the scenario steps against a live session.
334
+ if (scenario) {
335
+ const steps = renderer.parseScenarioSteps(scenario.steps)
336
+ const outDir = flagStr(args, "out-dir") ?? "."
337
+ // Screenshots require pixels; otherwise rasterize opportunistically (napi installed → failure
338
+ // PNGs and real Skia text metrics, absent → deterministic HeadlessHost metrics).
339
+ const needsRaster = steps.some((s) => s.screenshot !== undefined)
340
+ note(`Running ${steps.length} step${steps.length === 1 ? "" : "s"}…`)
341
+ const session = await renderer.createSession(js, { width, height, ...timing, raster: needsRaster ? true : "auto" })
342
+ try {
343
+ const result = await renderer.runScenario(session, steps, fileScenarioIO(outDir))
344
+ for (const w of [ ...warnings, ...session.warnings ]) warnErr(w)
345
+ for (const st of result.steps) {
346
+ logErr(st.ok ? `${c.green("✓")} ${st.desc}` : `${c.red("✗")} ${st.desc}\n ${c.red(st.error ?? "failed")}`)
347
+ }
348
+ if (!result.ok && result.failureArtifacts.length > 0) {
349
+ logErr(c.dim(` failing frame: ${result.failureArtifacts.map((p) => resolve(outDir, p)).join(", ")}`))
350
+ }
351
+ logErr(result.ok ? c.green(`✓ ${result.steps.length}/${steps.length} steps passed`) : c.red(`✗ failed at step ${result.steps.length} of ${steps.length}`))
352
+ return done((cb) => cb(), result.ok ? 0 : 1)
353
+ } finally {
354
+ session.dispose()
355
+ }
356
+ }
357
+
358
+ // --map [file]: draw the 3D scene's annotated top-down map (grid + XZ footprints + named
359
+ // markers) instead of a screenshot — the level-design view. Writes <file>.png + <file>.json
360
+ // (the same data, world-space, queryable). Default map.png. `--label-all` labels every object.
361
+ const mapFlag = flagStr(args, "map") ?? (flagBool(args, "map") ? "map.png" : undefined)
362
+ if (mapFlag !== undefined) {
363
+ note(`Rendering map…`)
364
+ const title = root.split(/[\\/]/).pop()
365
+ const { png, data, warnings: mapWarnings } = await renderer.renderToMap(js, {
366
+ ...timing, maxPx: Number(flagStr(args, "max-px")) || undefined, title, labelAll: flagBool(args, "label-all"),
367
+ })
368
+ for (const w of [ ...warnings, ...mapWarnings ]) warnErr(w)
369
+ const pngPath = mapFlag.endsWith(".png") ? mapFlag : mapFlag + ".png"
370
+ mkdirSync(dirname(resolve(pngPath)), { recursive: true })
371
+ writeFileSync(resolve(pngPath), png)
372
+ const jsonPath = pngPath.slice(0, -4) + ".json"
373
+ writeFileSync(resolve(jsonPath), JSON.stringify(data, null, 2))
374
+ note(`Wrote ${pngPath} + ${jsonPath}`)
375
+ return done((cb) => cb())
376
+ }
377
+
378
+ // --png [file]: rasterize a PNG (binary → always to a file, default screenshot.png).
379
+ const pngFlag = flagStr(args, "png") ?? (flagBool(args, "png") ? "screenshot.png" : undefined)
380
+ if (pngFlag !== undefined) {
381
+ note(screen ? `Rendering ${screen.label} (PNG)…` : "Rendering PNG…")
382
+ const clip = parseClip(flagStr(args, "clip"))
383
+ const { png, width: outW, height: outH, warnings: pngWarnings } = await renderer.renderToPng(js, {
384
+ width, height, scale: Number(flagStr(args, "scale")) || 2, ...timing, clip,
385
+ })
386
+ for (const w of [ ...warnings, ...pngWarnings ]) warnErr(w)
387
+ if (isNullSink(pngFlag)) {
388
+ note(`Discarded PNG (${pngFlag})`)
389
+ } else {
390
+ mkdirSync(dirname(resolve(pngFlag)), { recursive: true }) // --png shots/home.png: make the folder, like the native path does
391
+ writeFileSync(resolve(pngFlag), png)
392
+ note(`Wrote ${pngFlag} (${png.length} bytes, ${outW}x${outH}${clip !== undefined ? ", clipped" : ""})`)
393
+ }
394
+ return done((cb) => cb())
395
+ }
396
+
397
+ note(screen ? `Rendering ${screen.label}…` : "Rendering…")
398
+ const result = await renderer.renderToJson(js, { width, height, ...timing })
399
+
400
+ for (const w of [ ...warnings, ...result.warnings ]) warnErr(w)
401
+
402
+ const out = flagStr(args, "o", "out")
403
+ if (out && isNullSink(out)) {
404
+ note(`Discarded output (${out})`)
405
+ return done((cb) => cb())
406
+ }
407
+ if (out) {
408
+ writeFileSync(resolve(out), result.text)
409
+ note(`Wrote ${out} (${result.text.length} bytes)`)
410
+ return done((cb) => cb())
411
+ }
412
+ const text = result.text.endsWith("\n") ? result.text : result.text + "\n"
413
+ return done((cb) => process.stdout.write(text, cb))
414
+ }
@@ -0,0 +1,148 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"
2
+ import { basename, dirname, join, resolve } from "node:path"
3
+ import { findProjectRoot, readManifest, type Manifest } from "../manifest"
4
+ import { compileSceneEditor } from "../compile/sceneCompile"
5
+ import { localShaderArtifact } from "../compile/shaders"
6
+ import { openBrowser } from "../browserAuth"
7
+ import { CliError, c, flagBool, flagStr, log, note, warnErr, type Args } from "../util"
8
+
9
+ /*
10
+ * `lecodes scene [path]` — the 3D scene editor (lecodes-tools/packages/lecodes-3d-editor) hosted on a local project,
11
+ * exactly the way `lecodes design` hosts the design board. Starts scene-editor's node:http server,
12
+ * injects the CLI's compile closure (chisel + the vendored SDK + a vendored harness), and opens the
13
+ * browser. `.scene.ts` files are edited visually (tree + inspector) and written back to disk;
14
+ * external (IDE) edits hot-reload over SSE.
15
+ *
16
+ * Root resolution mirrors `design`: inside a cloned project the whole project is the compile input;
17
+ * anywhere else it runs standalone against the current directory (or a folder/scene passed as `path`).
18
+ */
19
+
20
+ const DEFAULT_PORT = 4488
21
+
22
+ type SceneContext = {
23
+ root: string
24
+ /** Null in standalone mode: the scene lives outside any cloned lecodes project. */
25
+ manifest: Manifest | null
26
+ name: string
27
+ }
28
+
29
+ /** The nearest ancestor (inclusive) holding an `app.json` — a local project's root — or null. */
30
+ const findAppRoot = (start: string): string | null => {
31
+ let dir = start
32
+ while (true) {
33
+ if (existsSync(join(dir, "app.json"))) return dir
34
+ const parent = dirname(dir)
35
+ if (parent === dir) return null
36
+ dir = parent
37
+ }
38
+ }
39
+
40
+ /** The project a DIRECTORY belongs to: a cloned project's root, else the nearest ancestor with an
41
+ * app.json, else the directory itself. `lecodes thumbs` resolves its root this way — its
42
+ * positional args are targets, not the root, so it can't go through getContext. */
43
+ export const projectRootOf = (dir: string): string => {
44
+ try {
45
+ return findProjectRoot(dir)
46
+ } catch {
47
+ return findAppRoot(dir) ?? dir
48
+ }
49
+ }
50
+
51
+ const getContext = (args: Args): SceneContext => {
52
+ let root: string
53
+ let manifest: Manifest | null
54
+ try {
55
+ root = findProjectRoot(process.cwd())
56
+ manifest = readManifest(root)
57
+ } catch {
58
+ // Standalone (a local project without a platform manifest): the current directory, or a
59
+ // folder / .scene.ts passed as the positional arg. The whole-project compile needs the PROJECT
60
+ // root — a scene file's imports (`../weaponRig`) resolve against it — so walk up to the nearest
61
+ // `app.json`; only without one does a scene path fall back to its containing dir.
62
+ root = process.cwd()
63
+ manifest = null
64
+ const pathArg = args._[0]
65
+ if (pathArg) {
66
+ const abs = resolve(process.cwd(), pathArg)
67
+ if (existsSync(abs) && statSync(abs).isDirectory()) root = abs
68
+ else if (abs.endsWith(".scene.ts") && existsSync(abs)) root = findAppRoot(dirname(abs)) ?? dirname(abs)
69
+ else throw new CliError(`No such folder or .scene.ts: ${pathArg}`)
70
+ } else {
71
+ root = findAppRoot(root) ?? root
72
+ }
73
+ }
74
+ return { root, manifest, name: manifest?.name ?? basename(root) }
75
+ }
76
+
77
+ /** Register the scene server as a project-scoped MCP server so Claude Code auto-discovers its
78
+ * scene_state / check_scene tools. Merges into an existing .mcp.json (never clobbers a broken one).
79
+ * Ported from `lecodes design init`'s writer; keyed "lecodes-scene". */
80
+ const writeMcpConfig = (root: string, port: number) => {
81
+ const path = join(root, ".mcp.json")
82
+ const entry = { type: "http", url: `http://localhost:${port}/mcp` }
83
+ let config: { mcpServers?: Record<string, unknown> } = {}
84
+ if (existsSync(path)) {
85
+ try {
86
+ config = JSON.parse(readFileSync(path, "utf8"))
87
+ } catch {
88
+ warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-scene server by hand.")
89
+ return
90
+ }
91
+ }
92
+ config.mcpServers ??= {}
93
+ const existing = config.mcpServers["lecodes-scene"] as { url?: string } | undefined
94
+ if (existing?.url === entry.url) { note("kept .mcp.json (lecodes-scene MCP server)"); return }
95
+ config.mcpServers["lecodes-scene"] = entry
96
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
97
+ note(`${existing ? "updated" : "created"} .mcp.json (lecodes-scene MCP → ${entry.url})`)
98
+ }
99
+
100
+ /** Recursively find the first-level count of `.scene.ts` files under root (skip node_modules/dotdirs). */
101
+ const countScenes = (dir: string): number => {
102
+ let n = 0
103
+ for (const name of readdirSync(dir)) {
104
+ if (name === "node_modules" || name.startsWith(".")) continue
105
+ const full = join(dir, name)
106
+ if (statSync(full).isDirectory()) n += countScenes(full)
107
+ else if (name.endsWith(".scene.ts")) n++
108
+ }
109
+ return n
110
+ }
111
+
112
+ export const scene = async (args: Args) => {
113
+ const ctx = getContext(args)
114
+
115
+ let sceneServer: typeof import("lecodes-3d-editor/server")
116
+ try {
117
+ sceneServer = await import("lecodes-3d-editor/server")
118
+ } catch {
119
+ throw new CliError("The scene editor isn't installed. Run: npm install -g lecodes-3d-editor")
120
+ }
121
+
122
+ const port = Number(flagStr(args, "port")) || DEFAULT_PORT
123
+
124
+ if (countScenes(ctx.root) === 0) {
125
+ warnErr(`No .scene.ts file under ${ctx.root} yet — add one and the editor will pick it up.`)
126
+ }
127
+
128
+ const server = await sceneServer.startSceneServer({
129
+ root: ctx.root,
130
+ port,
131
+ log: note,
132
+ compile: (scenePath: string) => compileSceneEditor({
133
+ root: ctx.root, manifest: ctx.manifest, name: ctx.name, scenePath,
134
+ }),
135
+ // live material patches load a shader's compiled artifact (matc'd for the Rendered mode,
136
+ // the lite JSON otherwise) from the local shader cache
137
+ shaderArtifact: (shaderText, platform) => localShaderArtifact(ctx.root, shaderText, platform),
138
+ })
139
+
140
+ // Let Claude Code reach the running server over MCP (scene_state / check_scene), unless opted out.
141
+ if (!flagBool(args, "no-mcp")) writeMcpConfig(ctx.root, port)
142
+
143
+ log(`${c.bold("LeCodes Scene Editor")} — ${ctx.name}`)
144
+ log(`Editor: ${c.bold(server.url)}`)
145
+ log(`MCP: ${c.bold(server.url + "/mcp")}`)
146
+ log(`Watching: ${ctx.root} (scenes hot-reload on save; Ctrl+C to stop)`)
147
+ if (!flagBool(args, "no-open")) openBrowser(server.url)
148
+ }