lecodes-cli 0.20.1 → 1.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 (107) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +5920 -5022
  3. package/package.json +9 -7
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap-baked-lite.mat +6 -1
  7. package/runtime/materials/lightmap-baked.mat +6 -1
  8. package/runtime/materials/lightmap.mat +3 -0
  9. package/runtime/materials/particles-quad.mat +17 -9
  10. package/runtime/materials/particles.mat +17 -9
  11. package/runtime/scene-harness.json +1 -1
  12. package/runtime/sdk-types.json +1 -1
  13. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
  14. package/runtime/web/assets/{index-BTB5KV9x.js → index-Q06c6oHx.js} +1 -1
  15. package/runtime/web/embed.js +2 -2
  16. package/src/api.ts +1 -302
  17. package/src/cli/args.ts +37 -0
  18. package/src/cli/command.ts +113 -0
  19. package/src/cli/errors.ts +13 -0
  20. package/src/cli/help.ts +133 -0
  21. package/src/cli/index.ts +7 -0
  22. package/src/cli/output.ts +102 -0
  23. package/src/cli/run.ts +106 -0
  24. package/src/commands/{appAndroid.ts → app/android.ts} +451 -42
  25. package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
  26. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
  27. package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
  28. package/src/commands/{app.ts → app/index.ts} +397 -289
  29. package/src/commands/{appShared.ts → app/shared.ts} +30 -39
  30. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
  31. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  32. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
  33. package/src/commands/assets.ts +23 -21
  34. package/src/commands/clone.ts +48 -51
  35. package/src/commands/compile.ts +62 -88
  36. package/src/commands/create.ts +46 -47
  37. package/src/commands/design/comments.ts +361 -0
  38. package/src/commands/design/context.ts +101 -0
  39. package/src/commands/design/index.ts +391 -0
  40. package/src/commands/design/snapshot.ts +134 -0
  41. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  42. package/src/commands/desktop.ts +90 -82
  43. package/src/commands/dev.ts +201 -176
  44. package/src/commands/diff.ts +39 -51
  45. package/src/commands/index.ts +60 -0
  46. package/src/commands/{init.ts → init/index.ts} +199 -191
  47. package/src/commands/install.ts +80 -67
  48. package/src/commands/lightmap.ts +203 -171
  49. package/src/commands/link.ts +87 -94
  50. package/src/commands/login.ts +25 -23
  51. package/src/commands/navmesh.ts +168 -138
  52. package/src/commands/pn.ts +201 -244
  53. package/src/commands/pull.ts +49 -56
  54. package/src/commands/push.ts +48 -54
  55. package/src/commands/render.ts +158 -99
  56. package/src/commands/scene.ts +58 -55
  57. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  58. package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
  59. package/src/commands/shared.ts +79 -0
  60. package/src/commands/status.ts +22 -26
  61. package/src/commands/test.ts +398 -371
  62. package/src/commands/thumbs.ts +185 -175
  63. package/src/commands/update/index.ts +226 -0
  64. package/src/commands/{types.ts → update/types.ts} +31 -22
  65. package/src/compile/collect.ts +5 -5
  66. package/src/compile/collectLocal.ts +3 -3
  67. package/src/compile/designCompile.ts +3 -3
  68. package/src/compile/headlessBundle.ts +133 -129
  69. package/src/compile/projectCompile.ts +2 -2
  70. package/src/compile/sceneCompile.ts +10 -8
  71. package/src/compile/screenEntry.ts +123 -127
  72. package/src/compile/shaders.ts +3 -3
  73. package/src/dev/androidDev.ts +1 -1
  74. package/src/dev/devServer.ts +3 -3
  75. package/src/dev/webRunner.ts +1 -1
  76. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
  77. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
  78. package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
  79. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  80. package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
  81. package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
  82. package/src/{releases.ts → hosts/releases.ts} +1 -1
  83. package/src/index.ts +32 -480
  84. package/src/platform/api.ts +302 -0
  85. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  86. package/src/{config.ts → platform/config.ts} +1 -1
  87. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  88. package/src/{projectEnv.ts → project/env.ts} +33 -2
  89. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  90. package/src/{manifest.ts → project/manifest.ts} +1 -1
  91. package/src/{project.ts → project/materialize.ts} +2 -2
  92. package/src/project/paths.ts +20 -0
  93. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  94. package/src/{types.ts → project/types.ts} +0 -0
  95. package/runtime/web/assets/createViewerLite-C_bkKXLg.js +0 -852
  96. package/src/commands/design.ts +0 -846
  97. package/src/commands/update.ts +0 -211
  98. package/src/util.ts +0 -146
  99. /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
  100. /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
  101. /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
  102. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  103. /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
  104. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  105. /package/src/{peers.ts → hosts/peers.ts} +0 -0
  106. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
  107. /package/src/{ignore.ts → project/ignore.ts} +0 -0
@@ -2,16 +2,18 @@ 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
8
  import { resolveWebRunnerDir } from "../dev/webRunner"
9
- import { openBrowser } from "../browserAuth"
10
- import { runDesktopDev } from "../desktopRenderer"
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 { readAppConfigAt } from "./app/shared"
13
13
  import { resolveScale } from "./desktop"
14
- import { CliError, c, flagBool, flagStr, log, note, toKey, warnErr, type Args } from "../util"
14
+ import { CliError, bool, c, defineCommand, log, note, num, str, warnErr } from "../cli"
15
+ import { bundleFlags } from "./shared"
16
+ import { toKey } from "../project/paths"
15
17
 
16
18
  /*
17
19
  * `lecodes dev` — run the project on a device straight from this machine, with hot reload.
@@ -28,6 +30,17 @@ import { CliError, c, flagBool, flagStr, log, note, toKey, warnErr, type Args }
28
30
  * web twins of the camera/map plugins, no wasm. Same bundle and hot reload; browser console
29
31
  * output streams back here like a device's. The URL is printed whenever the runner is available.
30
32
  *
33
+ * `--android` opens the dev URL in the LeCodes app on the phone plugged in over adb (adb reverse:
34
+ * no Wi-Fi/LAN address needed — see dev/androidDev.ts). With `--time <s>` the run is bounded —
35
+ * screenshot to --png, force-stop, exit — which is the shape a scripted check wants; without it
36
+ * the app runs until Ctrl+C, and Ctrl+C force-stops it (a device run has no frame cap, and a 3D
37
+ * scene left rendering heats a charging phone).
38
+ *
39
+ * `--desktop` opens the dev URL in the native host too (same server, same hot reload — the host
40
+ * speaks the websocket channel). The window closing does NOT stop the server: phones stay
41
+ * connected, and re-running with --desktop is the reopen. Host errors degrade to a warning — the
42
+ * QR path must keep working.
43
+ *
31
44
  * Works inside a cloned project or standalone in any folder with an entry .ts file.
32
45
  * The desktop host speaks websocket since 1.0.0 (host_ws); `?t=poll` remains the fallback
33
46
  * for older desktop builds (or any host without a websocket bridge).
@@ -94,185 +107,197 @@ const printQr = async (url: string) => {
94
107
  }
95
108
  }
96
109
 
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
- }
116
-
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)
126
-
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
- })
110
+ export default defineCommand({
111
+ name: "dev",
112
+ summary: "Run the project on a device / the desktop / the browser from this machine, with hot reload",
113
+ 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 (Web Lite runner) is served always; --web, --android and --desktop each also open the app on that host.",
114
+ flags: {
115
+ port: num("server port", { default: DEFAULT_PORT }),
116
+ host: str("LAN address to advertise (default: auto-detect)", { value: "<ip>" }),
117
+ entry: bundleFlags.entry,
118
+ "no-qr": bool("don't render the QR code"),
119
+ 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"),
120
+ serial: str("(--android) the device to use", { value: "<id>" }),
121
+ apk: str("(--android) install this build first", { value: "<file>" }),
122
+ package: str("(--android) the app's package (default com.letary.lecodes)", { value: "<name>" }),
123
+ time: num("(--android) bounded check: screenshot after this many seconds, force-stop, exit (exit 1 on a crash)", { value: "<s>" }),
124
+ png: str("(--android --time) where the device screenshot goes", { value: "<file>" }),
125
+ web: bool("also open the app in the browser (Web Lite host: same bundle + hot reload, camera/map plugins have web twins)"),
126
+ 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)"),
127
+ fps: bool("(--desktop) draw the host's frame counter"),
128
+ },
129
+ examples: ["lecodes dev", "lecodes dev --web", "lecodes dev --android --time 12 --png phone.png", "lecodes dev --desktop --fps"],
130
+ run: async ({ flags }) => {
131
+ // Inside a cloned project the server runs at its root; anywhere else it runs standalone against
132
+ // the current directory (no login / manifest needed) — same rule as `lecodes design`.
133
+ let root: string
134
+ let manifest: Manifest | null
135
+ try {
136
+ root = findProjectRoot(process.cwd())
137
+ manifest = readManifest(root)
138
+ } catch {
139
+ root = process.cwd()
140
+ manifest = null
141
+ }
142
+ const name = manifest?.name ?? basename(root)
140
143
 
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
- }
144
+ // Bun's node:http can't complete a websocket upgrade (writes to the upgraded socket are dropped),
145
+ // so the reload push would silently never reach devices. Node is the shipped runtime.
146
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
147
+ 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).")
148
+ }
148
149
 
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
- }
150
+ const port = flags.port || DEFAULT_PORT
151
+ const candidates = lanCandidates()
152
+ const host = flags.host ?? candidates[0]?.address
153
+ if (!host) throw new CliError("No LAN address found — pass one with --host <ip>.")
154
+ // A machine with several NICs can only be disambiguated by the human: name the runners-up so a
155
+ // device that can't reach the printed URL is one --host away from working, not a debugging session.
156
+ const alternatives = candidates.filter((c) => c.address !== host)
157
+ const origin = `http://${host}:${port}`
158
+ const token = loadDevToken(root)
162
159
 
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
- }
160
+ const webDir = resolveWebRunnerDir()
161
+ if (flags.web && !webDir) {
162
+ 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).")
163
+ }
164
+ const server = await startDevServer({
165
+ root, manifest, name, origin, port, token,
166
+ entryOverride: flags.entry,
167
+ webDir,
168
+ log: note,
169
+ }).catch((e: unknown) => {
170
+ const msg = e instanceof Error ? e.message : String(e)
171
+ throw new CliError(msg.includes("EADDRINUSE") ? `Port ${port} is already in use — pass --port <n>.` : msg)
172
+ })
182
173
 
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}`))
174
+ note("Compiling…")
175
+ const first = await server.recompile()
176
+ if (first.ok) {
177
+ for (const w of first.warnings) warnErr(w)
178
+ } else {
179
+ warnErr(`Compile failed — fix it and save; devices connect once it compiles:\n${first.error}`)
180
+ }
190
181
 
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.`)
182
+ // ---- watcher: debounced whole-project recompile; a successful compile broadcasts the reload ----
183
+ const ignore = loadIgnore(root)
184
+ const shouldRecompile = (file: string | null): boolean => {
185
+ if (file === null) return true // event without a filename — assume project content
186
+ const parts = file.replace(/\\/g, "/").split("/")
187
+ if (parts.some((p) => WATCH_SKIP_DIRS.has(p))) return false
188
+ if (WATCH_SKIP_FILES.has(parts[parts.length - 1])) return false
189
+ try {
190
+ const key = toKey(file)
191
+ if (ignore(key, false)) return false
192
+ } catch { return false }
193
+ return true
194
+ }
208
195
 
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
- }
196
+ const debug = process.env.LECODES_DEV_DEBUG === "1"
197
+ const underBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
198
+ let timer: NodeJS.Timeout | null = null
199
+ const onChange = (file: string | null) => {
200
+ if (debug) note(`[watch] event: ${file ?? "(null)"} → ${shouldRecompile(file) ? "recompile" : "skip"}`)
201
+ if (!shouldRecompile(file)) return
202
+ if (timer) clearTimeout(timer)
203
+ timer = setTimeout(async () => {
204
+ timer = null
205
+ const result = await server.recompile()
206
+ if (!result.ok) {
207
+ warnErr(`Compile failed (devices keep the last good version):\n${result.error}`)
208
+ } else if (result.changed) {
209
+ const devices = server.clientCount()
210
+ note(`✓ v${result.v} → ${devices} device${devices === 1 ? "" : "s"}`)
211
+ for (const w of result.warnings) warnErr(w)
212
+ }
213
+ }, DEBOUNCE_MS)
214
+ }
214
215
 
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
216
+ let watcher: FSWatcher | null = null
226
217
  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
218
+ watcher = watch(root, { recursive: true }, (_type, file) => onChange(file))
219
+ } catch {
220
+ warnErr("fs.watch(recursive) unavailable on this platform — hot reload disabled (restart to pick up changes).")
231
221
  }
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)
222
+ watcher?.on("error", (e) => warnErr(`Watcher error — hot reload may have stopped (restart lecodes dev): ${e.message}`))
223
+
224
+ // ---- banner ------------------------------------------------------------------------------------
225
+ log("")
226
+ log(`${c.bold("LeCodes Dev")} — ${name}`)
227
+ log("")
228
+ if (!flags["no-qr"]) await printQr(server.url)
229
+ log(`Scan the QR with the LeCodes app, or open: ${c.bold(server.url)}`)
230
+ // The browser runner: localhost is a secure context (camera works there), the LAN address is
231
+ // what a phone's browser can reach.
232
+ const webLocal = server.webUrl ? server.webUrl.replace(`http://${host}:`, "http://localhost:") : null
233
+ if (server.webUrl && webLocal) log(`In the browser (Web Lite): ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
234
+ note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
235
+ if (alternatives.length > 0) {
236
+ note(`Device can't reach it? This machine also has ${alternatives.map((a) => `${a.address} (${a.iface})`).join(", ")} — retry with --host <ip>.`)
237
237
  }
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)
238
+ if (existsSync(join(root, LECODES_DIR, "manifest.json"))) note(`Token stored in ${LECODES_DIR}/dev.json (delete it to rotate the URL).`)
239
+ log("")
240
+ log(`Watching ${root} — save a file to hot-reload. Ctrl+C to stop.`)
241
+
242
+ // --web: open the browser runner (the server keeps serving it either way).
243
+ if (flags.web && webLocal) {
244
+ note("Opening the browser…")
245
+ openBrowser(webLocal)
252
246
  }
253
- }
254
247
 
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
- }
248
+ if (flags.android) {
249
+ const seconds = flags.time ?? 0
250
+ const png = flags.png
251
+ // Under Bun the websocket channel never delivers (see the warning above): the phone would sit on
252
+ // "Connected — loading the project…" forever. The path-form poll url works on both runtimes.
253
+ const deviceUrl = `http://127.0.0.1:${port}${server.url.slice(origin.length)}`.replace(/\/dev\.js$/, underBun ? "/dev-poll.js" : "/dev.js")
254
+ let session: AndroidDevSession
255
+ try {
256
+ session = launchOnAndroid({ port, url: deviceUrl, serial: flags.serial, apk: flags.apk, packageName: flags.package })
257
+ } catch (e) {
258
+ server.close(); watcher?.close()
259
+ throw e
260
+ }
261
+ note(`Android: ${session.serial} → ${deviceUrl} (adb reverse; device logs stream here as [device:log])`)
262
+ const finish = (code: number) => {
263
+ session.stop()
264
+ server.close(); watcher?.close()
265
+ process.exit(code)
266
+ }
267
+ process.on("SIGINT", () => { note("Stopping the app on the phone…"); finish(0) })
268
+ if (seconds > 0) {
269
+ setTimeout(() => {
270
+ const crashes = androidCrashLines(session)
271
+ if (png) {
272
+ if (session.screenshot(png)) note(`Wrote ${png} (device screenshot after ${seconds} s)`)
273
+ else warnErr(`Screenshot failed (adb screencap).`)
274
+ }
275
+ if (crashes.length > 0) {
276
+ warnErr(`The app crashed on the phone:\n${crashes.join("\n")}`)
277
+ finish(1)
278
+ }
279
+ finish(0)
280
+ }, seconds * 1000)
281
+ }
282
+ }
283
+
284
+ if (flags.desktop) {
285
+ const appCfg = readAppConfigAt(root)
286
+ const win = appCfg?.desktop?.window
287
+ note("Opening LeCodes Desktop against the dev server…")
288
+ // scale IS forwarded (density changes how the app looks — dev should match production);
289
+ // fullscreen deliberately is NOT (a fullscreen dev window hides the terminal).
290
+ void runDesktopDev(server.url, {
291
+ width: win?.width, height: win?.height,
292
+ minWidth: win?.minWidth, minHeight: win?.minHeight,
293
+ resizable: win?.resizable,
294
+ title: win?.title ?? appCfg?.name ?? name,
295
+ scale: resolveScale(undefined, win?.scale),
296
+ fps: flags.fps,
297
+ }).then(
298
+ () => note("Desktop window closed — the dev server keeps running (Ctrl+C to stop)."),
299
+ (e: unknown) => warnErr(`Desktop host: ${e instanceof Error ? e.message : String(e)}`),
300
+ )
301
+ }
302
+ },
303
+ })
@@ -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 ")
@@ -0,0 +1,60 @@
1
+ import type { Group } from "../cli"
2
+ import app from "./app"
3
+ import assets from "./assets"
4
+ import clone from "./clone"
5
+ import compile from "./compile"
6
+ import create from "./create"
7
+ import design from "./design"
8
+ import desktop from "./desktop"
9
+ import dev from "./dev"
10
+ import diff from "./diff"
11
+ import init from "./init"
12
+ import install from "./install"
13
+ import lightmap from "./lightmap"
14
+ import link from "./link"
15
+ import login from "./login"
16
+ import navmesh from "./navmesh"
17
+ import pn from "./pn"
18
+ import pull from "./pull"
19
+ import push from "./push"
20
+ import render from "./render"
21
+ import scene from "./scene"
22
+ import shaders from "./shaders"
23
+ import status from "./status"
24
+ import test from "./test"
25
+ import thumbs from "./thumbs"
26
+ import update from "./update"
27
+
28
+ /*
29
+ * THE command list. Adding a command = one file with a `defineCommand` default export + one line
30
+ * here, in the group it belongs to (CONTRIBUTING.md). Order within a group is the help's order.
31
+ */
32
+ export const commandGroups = (opts: { install: boolean }): Group[] => [
33
+ { title: "Project", commands: [init, create, clone, link, status, diff, pull, push, login] },
34
+ { title: "Run & test", commands: [
35
+ dev,
36
+ compile,
37
+ render,
38
+ test,
39
+ desktop,
40
+ ] },
41
+ { title: "Editors", commands: [
42
+ design,
43
+ scene,
44
+ thumbs,
45
+ ] },
46
+ { title: "Content", commands: [
47
+ assets,
48
+ shaders,
49
+ lightmap,
50
+ navmesh,
51
+ ] },
52
+ { title: "Native apps", commands: [
53
+ app,
54
+ pn,
55
+ ] },
56
+ { title: "Maintenance", commands: [
57
+ update,
58
+ { ...install, hidden: !opts.install },
59
+ ] },
60
+ ]