lecodes-cli 0.20.2 → 2.0.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 (154) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +11052 -7592
  3. package/package.json +14 -8
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap.mat +117 -138
  7. package/runtime/materials/particles-quad.mat +6 -2
  8. package/runtime/materials/terrain-lightmap.mat +123 -0
  9. package/runtime/materials/terrain.mat +36 -55
  10. package/runtime/sdk-types.json +1 -1
  11. package/runtime/web/VERSION.json +5 -0
  12. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  13. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  14. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  15. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  16. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  17. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  18. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  19. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  20. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  21. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  22. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  23. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  24. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  25. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  26. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  27. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  28. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  29. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  30. package/runtime/web/index.html +36 -0
  31. package/runtime/web/player.js +21 -0
  32. package/runtime/web-page/index.html +41 -0
  33. package/runtime/web-page/page.css +1 -0
  34. package/runtime/web-page/page.js +31 -0
  35. package/src/api.ts +1 -302
  36. package/src/cli/args.ts +37 -0
  37. package/src/cli/command.ts +113 -0
  38. package/src/cli/errors.ts +13 -0
  39. package/src/cli/help.ts +158 -0
  40. package/src/cli/index.ts +7 -0
  41. package/src/cli/output.ts +102 -0
  42. package/src/cli/run.ts +106 -0
  43. package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
  44. package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
  45. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
  46. package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
  47. package/src/commands/{app.ts → app/index.ts} +400 -290
  48. package/src/commands/{appShared.ts → app/shared.ts} +43 -44
  49. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
  50. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  51. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
  52. package/src/commands/assets.ts +23 -21
  53. package/src/commands/clone.ts +48 -51
  54. package/src/commands/compile.ts +76 -89
  55. package/src/commands/create.ts +46 -47
  56. package/src/commands/design/comments.ts +361 -0
  57. package/src/commands/design/context.ts +101 -0
  58. package/src/commands/design/index.ts +391 -0
  59. package/src/commands/design/snapshot.ts +134 -0
  60. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  61. package/src/commands/desktop.ts +94 -82
  62. package/src/commands/dev.ts +260 -182
  63. package/src/commands/diff.ts +39 -51
  64. package/src/commands/index.ts +64 -0
  65. package/src/commands/{init.ts → init/index.ts} +195 -191
  66. package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
  67. package/src/commands/install.ts +80 -67
  68. package/src/commands/lightmap.ts +543 -252
  69. package/src/commands/link.ts +87 -94
  70. package/src/commands/login.ts +25 -23
  71. package/src/commands/navmesh.ts +169 -139
  72. package/src/commands/plugin.ts +46 -0
  73. package/src/commands/pn.ts +201 -244
  74. package/src/commands/pull.ts +49 -56
  75. package/src/commands/push.ts +48 -54
  76. package/src/commands/render.ts +176 -111
  77. package/src/commands/scene.ts +63 -55
  78. package/src/commands/serve.ts +58 -0
  79. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  80. package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
  81. package/src/commands/shared.ts +79 -0
  82. package/src/commands/status.ts +22 -26
  83. package/src/commands/test.ts +94 -63
  84. package/src/commands/thumbs.ts +185 -175
  85. package/src/commands/update/index.ts +226 -0
  86. package/src/commands/{types.ts → update/types.ts} +31 -22
  87. package/src/compile/collect.ts +5 -5
  88. package/src/compile/collectLocal.ts +3 -3
  89. package/src/compile/designCompile.ts +4 -4
  90. package/src/compile/headlessBundle.ts +16 -12
  91. package/src/compile/nativeStack.ts +1 -1
  92. package/src/compile/projectCompile.ts +12 -4
  93. package/src/compile/sceneCompile.ts +14 -26
  94. package/src/compile/screenEntry.ts +123 -127
  95. package/src/compile/shaders.ts +3 -3
  96. package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
  97. package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
  98. package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
  99. package/src/dev/androidDev.ts +1 -1
  100. package/src/dev/clientTemplates.ts +18 -14
  101. package/src/dev/devServer.ts +78 -41
  102. package/src/dev/iosDev.ts +180 -0
  103. package/src/dev/webRunner.ts +83 -15
  104. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
  105. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
  106. package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
  107. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  108. package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
  109. package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
  110. package/src/{peers.ts → hosts/peers.ts} +2 -2
  111. package/src/{releases.ts → hosts/releases.ts} +1 -1
  112. package/src/index.ts +35 -480
  113. package/src/platform/api.ts +302 -0
  114. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  115. package/src/{config.ts → platform/config.ts} +1 -1
  116. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  117. package/src/plugin/contract.ts +438 -0
  118. package/src/plugin/emitKotlin.ts +357 -0
  119. package/src/plugin/emitSdk.ts +259 -0
  120. package/src/plugin/emitSwift.ts +336 -0
  121. package/src/plugin/emitWeb.ts +383 -0
  122. package/src/plugin/generate.ts +157 -0
  123. package/src/plugin/lines.ts +28 -0
  124. package/src/plugin/lower.ts +231 -0
  125. package/src/plugin/manifest.ts +192 -0
  126. package/src/{projectEnv.ts → project/env.ts} +33 -2
  127. package/src/{ignore.ts → project/ignore.ts} +164 -163
  128. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  129. package/src/{manifest.ts → project/manifest.ts} +1 -1
  130. package/src/{project.ts → project/materialize.ts} +2 -2
  131. package/src/project/paths.ts +20 -0
  132. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  133. package/src/{types.ts → project/types.ts} +0 -0
  134. package/runtime/materials/lightmap-baked-lite.mat +0 -175
  135. package/runtime/materials/lightmap-baked.mat +0 -176
  136. package/runtime/scene-harness.json +0 -1
  137. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  138. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  139. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  140. package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
  141. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  142. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  143. package/runtime/web/assets/index-BMt7AnC5.js +0 -2
  144. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  145. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  146. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  147. package/runtime/web/embed.html +0 -32
  148. package/runtime/web/embed.js +0 -171
  149. package/src/commands/design.ts +0 -846
  150. package/src/commands/update.ts +0 -211
  151. package/src/util.ts +0 -146
  152. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  153. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  154. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
@@ -2,31 +2,51 @@ import { existsSync, mkdirSync, readFileSync, watch, writeFileSync, type FSWatch
2
2
  import { randomBytes } from "node:crypto"
3
3
  import { networkInterfaces } from "node:os"
4
4
  import { basename, join } from "node:path"
5
- import { findProjectRoot, readManifest, LECODES_DIR, type Manifest } from "../manifest"
6
- import { loadIgnore } from "../ignore"
5
+ import { findProjectRoot, readManifest, LECODES_DIR, type Manifest } from "../project/manifest"
6
+ import { loadIgnore } from "../project/ignore"
7
7
  import { startDevServer } from "../dev/devServer"
8
- import { resolveWebRunnerDir } from "../dev/webRunner"
9
- import { openBrowser } from "../browserAuth"
10
- import { runDesktopDev } from "../desktopRenderer"
8
+ import { resolveWebRunner } from "../dev/webRunner"
9
+ import { openBrowser } from "../platform/browserAuth"
10
+ import { runDesktopDev } from "../hosts/desktopRenderer"
11
11
  import { androidCrashLines, launchOnAndroid, type AndroidDevSession } from "../dev/androidDev"
12
- import { readAppConfigAt } from "./appShared"
12
+ import { launchOnIos, type IosDevSession } from "../dev/iosDev"
13
+ import { readAppConfigAt } from "./app/shared"
13
14
  import { resolveScale } from "./desktop"
14
- import { CliError, c, flagBool, flagStr, log, note, toKey, warnErr, type Args } from "../util"
15
+ import { CliError, bool, c, defineCommand, log, note, num, str, warnErr } from "../cli"
16
+ import { bundleFlags } from "./shared"
17
+ import { toKey } from "../project/paths"
15
18
 
16
19
  /*
17
20
  * `lecodes dev` — run the project on a device straight from this machine, with hot reload.
18
21
  *
19
22
  * Starts a LAN dev server (compile + assets + reload channel), prints a QR code, and watches the
20
23
  * working tree. The device opens the QR URL like any project URL; every save recompiles and
21
- * hot-swaps the running app (`_creatorUtils.run` — fresh context, rollback on error). Device
24
+ * hot-swaps the running app (`_creatorApp.run` — fresh context, rollback on error). Device
22
25
  * console output and errors stream back to this terminal. Fully local: no login, no le.codes
23
26
  * backend. Custom shaders compile locally too (matc, downloaded on first use — matcTool.ts);
24
27
  * only when matc can't be fetched do they fall back to server-compiled artifacts (push once).
25
28
  *
26
- * `--web` opens the same project in the browser: the dev server also serves the Web Lite runner
27
- * (viewer-lite's dist-embed, vendored as runtime/web) under /web/ — CSS-flexbox UI + OGL 3D + the
28
- * web twins of the camera/map plugins, no wasm. Same bundle and hot reload; browser console
29
- * output streams back here like a device's. The URL is printed whenever the runner is available.
29
+ * `--web` opens the same project in the browser: the dev server also serves, under /web/, the
30
+ * app in a device (lecodes-web-frame's page: the device, the orientation, the console) around the
31
+ * web player (lecodes-web-player's dist — the runtime as wasm: Filament, creator-2d, the tree UI,
32
+ * and the web halves of the plugins). A phone that opens the url gets the player alone, the app
33
+ * filling its window. Same bundle and hot reload; the browser's console output streams back here
34
+ * like a device's. The URL is printed whenever the player is available.
35
+ *
36
+ * `--android` opens the dev URL in the LeCodes app on the phone plugged in over adb (adb reverse:
37
+ * no Wi-Fi/LAN address needed — see dev/androidDev.ts). With `--time <s>` the run is bounded —
38
+ * screenshot to --png, force-stop, exit — which is the shape a scripted check wants; without it
39
+ * the app runs until Ctrl+C, and Ctrl+C force-stops it (a device run has no frame cap, and a 3D
40
+ * scene left rendering heats a charging phone).
41
+ *
42
+ * `--ios` is the Apple twin (dev/iosDev.ts): the LeCodes app on a booted simulator or a plugged-in
43
+ * iPhone through `xcrun simctl` / `devicectl`, the url as a launch argument. Same `--time` / `--png`
44
+ * shape (screenshots on a simulator).
45
+ *
46
+ * `--desktop` opens the dev URL in the native host too (same server, same hot reload — the host
47
+ * speaks the websocket channel). The window closing does NOT stop the server: phones stay
48
+ * connected, and re-running with --desktop is the reopen. Host errors degrade to a warning — the
49
+ * QR path must keep working.
30
50
  *
31
51
  * Works inside a cloned project or standalone in any folder with an entry .ts file.
32
52
  * The desktop host speaks websocket since 1.0.0 (host_ws); `?t=poll` remains the fallback
@@ -54,7 +74,7 @@ const WATCH_SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore"
54
74
  */
55
75
  const VIRTUAL_IFACE = /wsl|docker|hyper-v|vethernet|virtualbox|vmware|tap|tun|zerotier|tailscale|loopback/i
56
76
 
57
- const lanCandidates = (): { address: string, iface: string }[] => {
77
+ export const lanCandidates = (): { address: string, iface: string }[] => {
58
78
  const nets = networkInterfaces()
59
79
  const out: { address: string, iface: string, rank: number }[] = []
60
80
  for (const iface of Object.keys(nets)) {
@@ -94,185 +114,243 @@ const printQr = async (url: string) => {
94
114
  }
95
115
  }
96
116
 
97
- export const dev = async (args: Args) => {
98
- // Inside a cloned project the server runs at its root; anywhere else it runs standalone against
99
- // the current directory (no login / manifest needed) — same rule as `lecodes design`.
100
- let root: string
101
- let manifest: Manifest | null
102
- try {
103
- root = findProjectRoot(process.cwd())
104
- manifest = readManifest(root)
105
- } catch {
106
- root = process.cwd()
107
- manifest = null
108
- }
109
- const name = manifest?.name ?? basename(root)
110
-
111
- // Bun's node:http can't complete a websocket upgrade (writes to the upgraded socket are dropped),
112
- // so the reload push would silently never reach devices. Node is the shipped runtime.
113
- if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
114
- warnErr("Running under Bun: the websocket reload channel doesn't work here — run `lecodes dev` with Node (or use ?t=poll on the device URL).")
115
- }
117
+ export default defineCommand({
118
+ name: "dev",
119
+ summary: "Run the project on a device / the desktop / the browser from this machine, with hot reload",
120
+ description: "Starts a LAN dev server + prints a QR code; scan it with the LeCodes app. Saves recompile + hot-swap the running app; device logs/errors stream back here. No login needed. The /web/ URL (the web player) is served always; --web, --android and --desktop each also open the app on that host.",
121
+ flags: {
122
+ port: num("server port", { default: DEFAULT_PORT }),
123
+ host: str("LAN address to advertise (default: auto-detect)", { value: "<ip>" }),
124
+ entry: bundleFlags.entry,
125
+ "no-qr": bool("don't render the QR code"),
126
+ android: bool("open the dev url in the LeCodes app on the phone plugged in over adb (adb reverse: no Wi-Fi/LAN address needed); device logs stream here; Ctrl+C force-stops the app"),
127
+ serial: str("(--android) the device to use", { value: "<id>" }),
128
+ apk: str("(--android) install this build first", { value: "<file>" }),
129
+ package: str("(--android) the app's package (default com.letary.lecodes)", { value: "<name>" }),
130
+ time: num("(--android | --ios) bounded check: screenshot after this many seconds, force-stop, exit (exit 1 on a crash)", { value: "<s>" }),
131
+ png: str("(--android | --ios, with --time) where the device screenshot goes", { value: "<file>" }),
132
+ ios: bool("open the dev url in the LeCodes app on a booted simulator or a plugged-in iPhone (Mac + Xcode: simctl / devicectl); a simulator gets the 127.0.0.1 url, a phone the LAN url; Ctrl+C terminates the app"),
133
+ udid: str("(--ios) the simulator (udid or name) or device (udid or name) to use", { value: "<id>" }),
134
+ app: str("(--ios) install this LeCodesApp.app first", { value: "<dir>" }),
135
+ bundle: str("(--ios) the app's bundle id (default com.letary.lecodes)", { value: "<id>" }),
136
+ web: bool("also open the app in the browser (the web player in a device: same bundle + hot reload, the console)"),
137
+ desktop: bool("also open the app in the native desktop host (same hot reload; window from app.json's desktop block; closing it keeps the server)"),
138
+ fps: bool("(--desktop) draw the host's frame counter"),
139
+ },
140
+ examples: ["lecodes dev", "lecodes dev --web", "lecodes dev --android --time 12 --png phone.png", "lecodes dev --ios --time 12 --png sim.png", "lecodes dev --desktop --fps"],
141
+ run: async ({ flags }) => {
142
+ // Inside a cloned project the server runs at its root; anywhere else it runs standalone against
143
+ // the current directory (no login / manifest needed) — same rule as `lecodes design`.
144
+ let root: string
145
+ let manifest: Manifest | null
146
+ try {
147
+ root = findProjectRoot(process.cwd())
148
+ manifest = readManifest(root)
149
+ } catch {
150
+ root = process.cwd()
151
+ manifest = null
152
+ }
153
+ const name = manifest?.name ?? basename(root)
116
154
 
117
- const port = Number(flagStr(args, "port")) || DEFAULT_PORT
118
- const candidates = lanCandidates()
119
- const host = flagStr(args, "host") ?? candidates[0]?.address
120
- if (!host) throw new CliError("No LAN address found — pass one with --host <ip>.")
121
- // A machine with several NICs can only be disambiguated by the human: name the runners-up so a
122
- // device that can't reach the printed URL is one --host away from working, not a debugging session.
123
- const alternatives = candidates.filter((c) => c.address !== host)
124
- const origin = `http://${host}:${port}`
125
- const token = loadDevToken(root)
155
+ // Bun's node:http can't complete a websocket upgrade (writes to the upgraded socket are dropped),
156
+ // so the reload push would silently never reach devices: a phone that scanned a websocket url
157
+ // sat on "Connected — loading the project…" forever. Under Bun every device url — the QR, the
158
+ // printed one, what --ios / --android open — is the POLL form (/dev-poll.js: a path, since the
159
+ // launcher only fetches urls that end in .js). Node is the shipped runtime.
160
+ const underBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
161
+ if (underBun) {
162
+ warnErr("Running under Bun: no websocket reload channel here — devices poll instead (the QR points at /dev-poll.js). Node is the shipped runtime.")
163
+ }
126
164
 
127
- const webDir = resolveWebRunnerDir()
128
- if (flagBool(args, "web") && !webDir) {
129
- throw new CliError("No Web Lite runner found for --web: this CLI has no runtime/web (a monorepo checkout needs `bun run build:embed` in packages/viewer-lite).")
130
- }
131
- const server = await startDevServer({
132
- root, manifest, name, origin, port, token,
133
- entryOverride: flagStr(args, "entry"),
134
- webDir,
135
- log: note,
136
- }).catch((e: unknown) => {
137
- const msg = e instanceof Error ? e.message : String(e)
138
- throw new CliError(msg.includes("EADDRINUSE") ? `Port ${port} is already in use — pass --port <n>.` : msg)
139
- })
165
+ const port = flags.port || DEFAULT_PORT
166
+ const candidates = lanCandidates()
167
+ const host = flags.host ?? candidates[0]?.address
168
+ if (!host) throw new CliError("No LAN address found — pass one with --host <ip>.")
169
+ // A machine with several NICs can only be disambiguated by the human: name the runners-up so a
170
+ // device that can't reach the printed URL is one --host away from working, not a debugging session.
171
+ const alternatives = candidates.filter((c) => c.address !== host)
172
+ const origin = `http://${host}:${port}`
173
+ const token = loadDevToken(root)
140
174
 
141
- note("Compiling…")
142
- const first = await server.recompile()
143
- if (first.ok) {
144
- for (const w of first.warnings) warnErr(w)
145
- } else {
146
- warnErr(`Compile failed — fix it and save; devices connect once it compiles:\n${first.error}`)
147
- }
175
+ const web = resolveWebRunner(note)
176
+ if (flags.web && !web) {
177
+ throw new CliError("No web player found for --web: this CLI has no runtime/web (a monorepo checkout needs `bun run build` in hosts/web-player).")
178
+ }
179
+ const server = await startDevServer({
180
+ root, manifest, name, origin, port, token,
181
+ entryOverride: flags.entry,
182
+ web,
183
+ log: note,
184
+ }).catch((e: unknown) => {
185
+ const msg = e instanceof Error ? e.message : String(e)
186
+ throw new CliError(msg.includes("EADDRINUSE") ? `Port ${port} is already in use — pass --port <n>.` : msg)
187
+ })
148
188
 
149
- // ---- watcher: debounced whole-project recompile; a successful compile broadcasts the reload ----
150
- const ignore = loadIgnore(root)
151
- const shouldRecompile = (file: string | null): boolean => {
152
- if (file === null) return true // event without a filename — assume project content
153
- const parts = file.replace(/\\/g, "/").split("/")
154
- if (parts.some((p) => WATCH_SKIP_DIRS.has(p))) return false
155
- if (WATCH_SKIP_FILES.has(parts[parts.length - 1])) return false
156
- try {
157
- const key = toKey(file)
158
- if (ignore(key, false)) return false
159
- } catch { return false }
160
- return true
161
- }
189
+ note("Compiling…")
190
+ const first = await server.recompile()
191
+ if (first.ok) {
192
+ for (const w of first.warnings) warnErr(w)
193
+ } else {
194
+ warnErr(`Compile failed — fix it and save; devices connect once it compiles:\n${first.error}`)
195
+ }
162
196
 
163
- const debug = process.env.LECODES_DEV_DEBUG === "1"
164
- const underBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
165
- let timer: NodeJS.Timeout | null = null
166
- const onChange = (file: string | null) => {
167
- if (debug) note(`[watch] event: ${file ?? "(null)"} → ${shouldRecompile(file) ? "recompile" : "skip"}`)
168
- if (!shouldRecompile(file)) return
169
- if (timer) clearTimeout(timer)
170
- timer = setTimeout(async () => {
171
- timer = null
172
- const result = await server.recompile()
173
- if (!result.ok) {
174
- warnErr(`Compile failed (devices keep the last good version):\n${result.error}`)
175
- } else if (result.changed) {
176
- const devices = server.clientCount()
177
- note(`✓ v${result.v} → ${devices} device${devices === 1 ? "" : "s"}`)
178
- for (const w of result.warnings) warnErr(w)
179
- }
180
- }, DEBOUNCE_MS)
181
- }
197
+ // ---- watcher: debounced whole-project recompile; a successful compile broadcasts the reload ----
198
+ const ignore = loadIgnore(root)
199
+ const shouldRecompile = (file: string | null): boolean => {
200
+ if (file === null) return true // event without a filename — assume project content
201
+ const parts = file.replace(/\\/g, "/").split("/")
202
+ if (parts.some((p) => WATCH_SKIP_DIRS.has(p))) return false
203
+ if (WATCH_SKIP_FILES.has(parts[parts.length - 1])) return false
204
+ try {
205
+ const key = toKey(file)
206
+ if (ignore(key, false)) return false
207
+ } catch { return false }
208
+ return true
209
+ }
182
210
 
183
- let watcher: FSWatcher | null = null
184
- try {
185
- watcher = watch(root, { recursive: true }, (_type, file) => onChange(file))
186
- } catch {
187
- warnErr("fs.watch(recursive) unavailable on this platform — hot reload disabled (restart to pick up changes).")
188
- }
189
- watcher?.on("error", (e) => warnErr(`Watcher error — hot reload may have stopped (restart lecodes dev): ${e.message}`))
211
+ const debug = process.env.LECODES_DEV_DEBUG === "1"
212
+ let timer: NodeJS.Timeout | null = null
213
+ const onChange = (file: string | null) => {
214
+ if (debug) note(`[watch] event: ${file ?? "(null)"} → ${shouldRecompile(file) ? "recompile" : "skip"}`)
215
+ if (!shouldRecompile(file)) return
216
+ if (timer) clearTimeout(timer)
217
+ timer = setTimeout(async () => {
218
+ timer = null
219
+ const result = await server.recompile()
220
+ if (!result.ok) {
221
+ warnErr(`Compile failed (devices keep the last good version):\n${result.error}`)
222
+ } else if (result.changed) {
223
+ const devices = server.clientCount()
224
+ note(`✓ v${result.v} → ${devices} device${devices === 1 ? "" : "s"}`)
225
+ for (const w of result.warnings) warnErr(w)
226
+ }
227
+ }, DEBOUNCE_MS)
228
+ }
190
229
 
191
- // ---- banner ------------------------------------------------------------------------------------
192
- log("")
193
- log(`${c.bold("LeCodes Dev")} — ${name}`)
194
- log("")
195
- if (!flagBool(args, "no-qr")) await printQr(server.url)
196
- log(`Scan the QR with the LeCodes app, or open: ${c.bold(server.url)}`)
197
- // The browser runner: localhost is a secure context (camera works there), the LAN address is
198
- // what a phone's browser can reach.
199
- const webLocal = server.webUrl ? server.webUrl.replace(`http://${host}:`, "http://localhost:") : null
200
- if (server.webUrl && webLocal) log(`In the browser (Web Lite): ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
201
- note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
202
- if (alternatives.length > 0) {
203
- note(`Device can't reach it? This machine also has ${alternatives.map((a) => `${a.address} (${a.iface})`).join(", ")} — retry with --host <ip>.`)
204
- }
205
- if (existsSync(join(root, LECODES_DIR, "manifest.json"))) note(`Token stored in ${LECODES_DIR}/dev.json (delete it to rotate the URL).`)
206
- log("")
207
- log(`Watching ${root} — save a file to hot-reload. Ctrl+C to stop.`)
230
+ let watcher: FSWatcher | null = null
231
+ try {
232
+ watcher = watch(root, { recursive: true }, (_type, file) => onChange(file))
233
+ } catch {
234
+ warnErr("fs.watch(recursive) unavailable on this platform — hot reload disabled (restart to pick up changes).")
235
+ }
236
+ watcher?.on("error", (e) => warnErr(`Watcher error — hot reload may have stopped (restart lecodes dev): ${e.message}`))
208
237
 
209
- // --web: open the browser runner (the server keeps serving it either way).
210
- if (flagBool(args, "web") && webLocal) {
211
- note("Opening the browser…")
212
- openBrowser(webLocal)
213
- }
238
+ // ---- banner ------------------------------------------------------------------------------------
239
+ log("")
240
+ log(`${c.bold("LeCodes Dev")} — ${name}`)
241
+ log("")
242
+ // The url a device opens: the websocket form, or the poll form under Bun (see above).
243
+ const deviceUrl = underBun ? server.url.replace(/\/dev\.js$/, "/dev-poll.js") : server.url
244
+ if (!flags["no-qr"]) await printQr(deviceUrl)
245
+ log(`Scan the QR with the LeCodes app, or open: ${c.bold(deviceUrl)}`)
246
+ // The browser runner: localhost is a secure context (camera works there), the LAN address is
247
+ // what a phone's browser can reach.
248
+ const webLocal = server.webUrl ? server.webUrl.replace(`http://${host}:`, "http://localhost:") : null
249
+ if (server.webUrl && webLocal) log(`In the browser: ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
250
+ if (!underBun) note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
251
+ if (alternatives.length > 0) {
252
+ note(`Device can't reach it? This machine also has ${alternatives.map((a) => `${a.address} (${a.iface})`).join(", ")} — retry with --host <ip>.`)
253
+ }
254
+ if (existsSync(join(root, LECODES_DIR, "manifest.json"))) note(`Token stored in ${LECODES_DIR}/dev.json (delete it to rotate the URL).`)
255
+ log("")
256
+ log(`Watching ${root} — save a file to hot-reload. Ctrl+C to stop.`)
214
257
 
215
- // --android: open the dev URL in the LeCodes app on the phone (adb; see dev/androidDev.ts). With
216
- // --time <s> the run is bounded — screenshot to --png, force-stop, exit — which is the shape a
217
- // scripted check wants; without it the app runs until Ctrl+C, and Ctrl+C force-stops it (a
218
- // device run has no frame cap, and a 3D scene left rendering heats a charging phone).
219
- if (flagBool(args, "android")) {
220
- const seconds = Number(flagStr(args, "time")) || 0
221
- const png = flagStr(args, "png")
222
- // Under Bun the websocket channel never delivers (see the warning above): the phone would sit on
223
- // "Connected — loading the project…" forever. The path-form poll url works on both runtimes.
224
- const deviceUrl = `http://127.0.0.1:${port}${server.url.slice(origin.length)}`.replace(/\/dev\.js$/, underBun ? "/dev-poll.js" : "/dev.js")
225
- let session: AndroidDevSession
226
- try {
227
- session = launchOnAndroid({ port, url: deviceUrl, serial: flagStr(args, "serial"), apk: flagStr(args, "apk"), packageName: flagStr(args, "package") })
228
- } catch (e) {
229
- server.close(); watcher?.close()
230
- throw e
258
+ // --web: open the browser runner (the server keeps serving it either way).
259
+ if (flags.web && webLocal) {
260
+ note("Opening the browser…")
261
+ openBrowser(webLocal)
231
262
  }
232
- note(`Android: ${session.serial} → ${deviceUrl} (adb reverse; device logs stream here as [device:log])`)
233
- const finish = (code: number) => {
234
- session.stop()
235
- server.close(); watcher?.close()
236
- process.exit(code)
263
+
264
+ if (flags.android) {
265
+ const seconds = flags.time ?? 0
266
+ const png = flags.png
267
+ // adb reverse: the phone reaches the server through its own loopback.
268
+ const androidUrl = `http://127.0.0.1:${port}${deviceUrl.slice(origin.length)}`
269
+ let session: AndroidDevSession
270
+ try {
271
+ session = launchOnAndroid({ port, url: androidUrl, serial: flags.serial, apk: flags.apk, packageName: flags.package })
272
+ } catch (e) {
273
+ server.close(); watcher?.close()
274
+ throw e
275
+ }
276
+ note(`Android: ${session.serial} → ${androidUrl} (adb reverse; device logs stream here as [device:log])`)
277
+ const finish = (code: number) => {
278
+ session.stop()
279
+ server.close(); watcher?.close()
280
+ process.exit(code)
281
+ }
282
+ process.on("SIGINT", () => { note("Stopping the app on the phone…"); finish(0) })
283
+ if (seconds > 0) {
284
+ setTimeout(() => {
285
+ const crashes = androidCrashLines(session)
286
+ if (png) {
287
+ if (session.screenshot(png)) note(`Wrote ${png} (device screenshot after ${seconds} s)`)
288
+ else warnErr(`Screenshot failed (adb screencap).`)
289
+ }
290
+ if (crashes.length > 0) {
291
+ warnErr(`The app crashed on the phone:\n${crashes.join("\n")}`)
292
+ finish(1)
293
+ }
294
+ finish(0)
295
+ }, seconds * 1000)
296
+ }
237
297
  }
238
- process.on("SIGINT", () => { note("Stopping the app on the phone…"); finish(0) })
239
- if (seconds > 0) {
240
- setTimeout(() => {
241
- const crashes = androidCrashLines(session)
242
- if (png) {
243
- if (session.screenshot(png)) note(`Wrote ${png} (device screenshot after ${seconds} s)`)
244
- else warnErr(`Screenshot failed (adb screencap).`)
245
- }
246
- if (crashes.length > 0) {
247
- warnErr(`The app crashed on the phone:\n${crashes.join("\n")}`)
248
- finish(1)
249
- }
250
- finish(0)
251
- }, seconds * 1000)
298
+
299
+ if (flags.ios) {
300
+ const seconds = flags.time ?? 0
301
+ const png = flags.png
302
+ // A simulator shares the Mac's network: the loopback url, no Wi-Fi involved. A phone gets the
303
+ // LAN url the QR shows.
304
+ const path = deviceUrl.slice(origin.length)
305
+ let session: IosDevSession
306
+ try {
307
+ session = launchOnIos({ simulatorUrl: `http://127.0.0.1:${port}${path}`, deviceUrl, udid: flags.udid, app: flags.app, bundleId: flags.bundle })
308
+ } catch (e) {
309
+ server.close(); watcher?.close()
310
+ throw e
311
+ }
312
+ note(`iOS: ${session.name} → ${session.kind === "simulator" ? `http://127.0.0.1:${port}${path}` : deviceUrl} (device logs stream here as [device:log])`)
313
+ const finish = (code: number) => {
314
+ session.stop()
315
+ server.close(); watcher?.close()
316
+ process.exit(code)
317
+ }
318
+ process.on("SIGINT", () => { note(`Stopping the app on ${session.name}…`); finish(0) })
319
+ if (seconds > 0) {
320
+ setTimeout(() => {
321
+ const crashes = session.crashLines()
322
+ if (png) {
323
+ if (session.screenshot(png)) note(`Wrote ${png} (screenshot after ${seconds} s)`)
324
+ else warnErr(session.kind === "simulator" ? "Screenshot failed (simctl io screenshot)." : "Screenshots need a simulator (no capture channel to a phone).")
325
+ }
326
+ if (crashes.length > 0) {
327
+ warnErr(`The app crashed:\n${crashes.join("\n")}`)
328
+ finish(1)
329
+ }
330
+ finish(0)
331
+ }, seconds * 1000)
332
+ }
252
333
  }
253
- }
254
334
 
255
- // --desktop: open the dev URL in the native host too (same server, same hot reload — the host
256
- // speaks the websocket channel). Window config comes from app.json's desktop block. The window
257
- // closing does NOT stop the server: phones stay connected, and re-running with --desktop is the
258
- // reopen. Errors (host not downloadable, unsupported OS) degrade to a warning — the QR path
259
- // must keep working.
260
- if (flagBool(args, "desktop")) {
261
- const appCfg = readAppConfigAt(root)
262
- const win = appCfg?.desktop?.window
263
- note("Opening LeCodes Desktop against the dev server…")
264
- // scale IS forwarded (density changes how the app looks — dev should match production);
265
- // fullscreen deliberately is NOT (a fullscreen dev window hides the terminal).
266
- void runDesktopDev(server.url, {
267
- width: win?.width, height: win?.height,
268
- minWidth: win?.minWidth, minHeight: win?.minHeight,
269
- resizable: win?.resizable,
270
- title: win?.title ?? appCfg?.name ?? name,
271
- scale: resolveScale(undefined, win?.scale),
272
- fps: flagBool(args, "fps"),
273
- }).then(
274
- () => note("Desktop window closed — the dev server keeps running (Ctrl+C to stop)."),
275
- (e: unknown) => warnErr(`Desktop host: ${e instanceof Error ? e.message : String(e)}`),
276
- )
277
- }
278
- }
335
+ if (flags.desktop) {
336
+ const appCfg = readAppConfigAt(root)
337
+ const win = appCfg?.desktop?.window
338
+ note("Opening LeCodes Desktop against the dev server…")
339
+ // scale IS forwarded (density changes how the app looks — dev should match production);
340
+ // fullscreen deliberately is NOT (a fullscreen dev window hides the terminal).
341
+ void runDesktopDev(server.url, {
342
+ width: win?.width, height: win?.height,
343
+ minWidth: win?.minWidth, minHeight: win?.minHeight,
344
+ resizable: win?.resizable,
345
+ title: win?.title ?? appCfg?.name ?? name,
346
+ scale: resolveScale(undefined, win?.scale),
347
+ // The frame-rate cap is forwarded too: it changes how the game FEELS, and dev should match production.
348
+ frameRate: appCfg?.desktop?.frameRate,
349
+ fps: flags.fps,
350
+ }).then(
351
+ () => note("Desktop window closed — the dev server keeps running (Ctrl+C to stop)."),
352
+ (e: unknown) => warnErr(`Desktop host: ${e instanceof Error ? e.message : String(e)}`),
353
+ )
354
+ }
355
+ },
356
+ })
@@ -1,59 +1,47 @@
1
- import { getProject } from "../api"
2
- import { loadConfig, requireApiUrl, requireToken } from "../config"
3
- import { findProjectRoot, readManifest } from "../manifest"
4
- import { scanProject } from "../localFiles"
5
- import { computeServerDiff, type ServerDiffEntry } from "../serverDiff"
6
- import { unifiedDiff } from "../textDiff"
7
- import { c, info, log, flagBool, toKey, type Args } from "../util"
1
+ import { bool, c, defineCommand, info, log } from "../cli"
2
+ import { getProject } from "../platform/api"
3
+ import { computeServerDiff, type ServerDiffEntry } from "../platform/serverDiff"
4
+ import { scanProject } from "../project/localFiles"
5
+ import { toKey } from "../project/paths"
6
+ import { unifiedDiff } from "../project/textDiff"
7
+ import { linkedProject } from "./shared"
8
8
 
9
9
  /*
10
- * `lecodes diff [path…]` — compare the local working tree against the project's CURRENT server state
11
- * and print a unified diff. The "+" side is local, the "-" side is the server, so it answers "what
12
- * would my push change?" — including any edits made on the server (e.g. in the web editor) since you
13
- * cloned. The headline use is conflict resolution before `push --force`.
14
- *
15
- * --name-only list changed paths + status instead of full diffs
16
- * [path…] limit to these files / folders
10
+ * `lecodes diff [path…]` — the local working tree against the project's CURRENT server state as a
11
+ * unified diff. "+" is local, "-" is the server, so it answers "what would my push change?" —
12
+ * including edits made on the server (the web editor) since the clone. The headline use is
13
+ * conflict resolution before `push --force`.
17
14
  */
18
- export const diff = async (args: Args) => {
19
- const root = findProjectRoot(process.cwd())
20
- const manifest = readManifest(root)
21
- const config = loadConfig()
22
- const apiUrl = requireApiUrl(config)
23
- const token = requireToken(config)
15
+ export default defineCommand({
16
+ name: "diff",
17
+ summary: "Show a unified diff of local vs the current server state",
18
+ usage: "[path…]",
19
+ flags: { "name-only": bool("list changed paths + status only") },
20
+ run: async ({ args, flags }) => {
21
+ const { root, manifest, apiUrl, token } = linkedProject()
22
+ info(c.dim("Comparing with the server…"))
23
+ const project = await getProject(apiUrl, token, manifest.uuid)
24
+ const scan = scanProject(root, manifest)
25
+ let entries = computeServerDiff(project, scan)
24
26
 
25
- info(c.dim("Comparing with the server…"))
26
- const project = await getProject(apiUrl, token, manifest.uuid)
27
- const scan = scanProject(root, manifest)
28
- let entries = computeServerDiff(project, scan)
29
-
30
- // Optional path filter (a file, or a folder prefix).
31
- if (args._.length > 0) {
32
- const keys = args._.map(toKey)
33
- entries = entries.filter(e => keys.some(k => e.path === k || e.path.startsWith(k + "/")))
34
- }
35
-
36
- if (entries.length === 0) {
37
- info(c.dim("No differences between local and server."))
38
- return
39
- }
40
-
41
- if (flagBool(args, "name-only")) {
42
- for (const e of entries) log(statusLabel(e) + e.path + (e.binary ? c.dim(" (binary)") : ""))
43
- return
44
- }
27
+ // Optional path filter (a file, or a folder prefix).
28
+ if (args.length > 0) {
29
+ const keys = args.map(toKey)
30
+ entries = entries.filter(e => keys.some(k => e.path === k || e.path.startsWith(k + "/")))
31
+ }
32
+ if (entries.length === 0) { info(c.dim("No differences between local and server.")); return }
45
33
 
46
- for (const e of entries) {
47
- if (e.binary) {
48
- log(statusLabel(e) + e.path + c.dim(" (binary — content not shown)"))
49
- continue
34
+ if (flags["name-only"]) {
35
+ for (const e of entries) log(statusLabel(e) + e.path + (e.binary ? c.dim(" (binary)") : ""))
36
+ return
37
+ }
38
+ for (const e of entries) {
39
+ if (e.binary) { log(statusLabel(e) + e.path + c.dim(" (binary — content not shown)")); continue }
40
+ const d = unifiedDiff(e.serverText ?? "", e.localText ?? "", `server${e.path}`, `local${e.path}`)
41
+ if (d) log("\n" + d)
50
42
  }
51
- const d = unifiedDiff(e.serverText ?? "", e.localText ?? "", `server${e.path}`, `local${e.path}`)
52
- if (d) log("\n" + d)
53
- }
54
- }
43
+ },
44
+ })
55
45
 
56
46
  const statusLabel = (e: ServerDiffEntry): string =>
57
- e.status === "added" ? c.green(" added ")
58
- : e.status === "removed" ? c.red(" removed ")
59
- : c.yellow(" modified ")
47
+ e.status === "added" ? c.green(" added ") : e.status === "removed" ? c.red(" removed ") : c.yellow(" modified ")