lecodes-cli 2.0.2 → 2.0.4
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.
- package/dist/index.js +1013 -127
- package/package.json +13 -12
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/{inspect-C7x78PNs.js → inspect-DdJ50Kun.js} +2 -2
- package/runtime/web/player.js +18 -18
- package/runtime/web-lite/assets/canvas-C6G_Pb2Q.js +1 -0
- package/runtime/web-lite/assets/pager-O712dZv9.js +1 -0
- package/runtime/web-lite/assets/roboto-cyrillic-DVBNKCCH.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-cyrillic-ext-1gPz9dK9.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-greek-DVLYEgdx.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-greek-ext-D98D44EL.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-latin-cAJwcdBQ.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-latin-ext-t610Sqxq.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-rest-C7JkyNHT.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-vietnamese-w-p-nKZZ.woff2 +0 -0
- package/runtime/web-lite/assets/tween-t_XDy3Nd.js +1 -0
- package/runtime/web-lite/assets/vlist-gEB8Sn0S.js +1 -0
- package/runtime/web-lite/features.json +51 -0
- package/runtime/web-lite/index.html +23 -0
- package/runtime/web-lite/lite.js +249 -0
- package/src/commands/design/comments.ts +1 -1
- package/src/commands/design/context.ts +1 -1
- package/src/commands/design/index.ts +1 -1
- package/src/commands/dev.ts +17 -53
- package/src/commands/index.ts +2 -0
- package/src/commands/test.ts +55 -6
- package/src/commands/update/index.ts +8 -5
- package/src/commands/web.ts +171 -0
- package/src/compile/fonts.ts +5 -2
- package/src/dev/devServer.ts +20 -1
- package/src/dev/watchProject.ts +53 -0
- package/src/dev/webRunner.ts +10 -0
- package/src/hosts/archives.ts +66 -0
- package/src/hosts/liteBrowser.mjs +86 -0
- package/src/hosts/liteRunner.ts +386 -0
- package/src/hosts/peerInstall.ts +12 -8
- package/src/web/build.ts +194 -0
- package/src/web/preview.ts +50 -0
package/src/commands/dev.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { randomBytes } from "node:crypto"
|
|
3
3
|
import { networkInterfaces } from "node:os"
|
|
4
4
|
import { basename, join } from "node:path"
|
|
5
5
|
import { findProjectRoot, readManifest, LECODES_DIR, type Manifest } from "../project/manifest"
|
|
6
|
-
import { loadIgnore } from "../project/ignore"
|
|
7
6
|
import { startDevServer } from "../dev/devServer"
|
|
8
7
|
import { resolveWebRunner } from "../dev/webRunner"
|
|
8
|
+
import { watchProject } from "../dev/watchProject"
|
|
9
9
|
import { openBrowser } from "../platform/browserAuth"
|
|
10
10
|
import { runDesktopDev } from "../hosts/desktopRenderer"
|
|
11
11
|
import { androidCrashLines, launchOnAndroid, type AndroidDevSession } from "../dev/androidDev"
|
|
@@ -14,7 +14,6 @@ import { readAppConfigAt } from "./app/shared"
|
|
|
14
14
|
import { resolveScale } from "./desktop"
|
|
15
15
|
import { CliError, bool, c, defineCommand, log, note, num, str, warnErr } from "../cli"
|
|
16
16
|
import { bundleFlags } from "./shared"
|
|
17
|
-
import { toKey } from "../project/paths"
|
|
18
17
|
|
|
19
18
|
/*
|
|
20
19
|
* `lecodes dev` — run the project on a device straight from this machine, with hot reload.
|
|
@@ -54,12 +53,6 @@ import { toKey } from "../project/paths"
|
|
|
54
53
|
*/
|
|
55
54
|
|
|
56
55
|
const DEFAULT_PORT = 4499
|
|
57
|
-
const DEBOUNCE_MS = 120
|
|
58
|
-
|
|
59
|
-
// Never trigger a recompile (mirrors scanLocal's skip lists + our own artifacts).
|
|
60
|
-
const WATCH_SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"])
|
|
61
|
-
const WATCH_SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"])
|
|
62
|
-
|
|
63
56
|
/*
|
|
64
57
|
* The origin a phone on the same Wi-Fi can reach, best first.
|
|
65
58
|
*
|
|
@@ -92,7 +85,7 @@ export const lanCandidates = (): { address: string, iface: string }[] => {
|
|
|
92
85
|
}
|
|
93
86
|
|
|
94
87
|
/** The per-project dev token, persisted so the QR/URL stays stable across runs. */
|
|
95
|
-
const loadDevToken = (root: string): string => {
|
|
88
|
+
export const loadDevToken = (root: string): string => {
|
|
96
89
|
const path = join(root, LECODES_DIR, "dev.json")
|
|
97
90
|
try {
|
|
98
91
|
const token = (JSON.parse(readFileSync(path, "utf8")) as { token?: string }).token
|
|
@@ -195,45 +188,16 @@ export default defineCommand({
|
|
|
195
188
|
}
|
|
196
189
|
|
|
197
190
|
// ---- watcher: debounced whole-project recompile; a successful compile broadcasts the reload ----
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return true
|
|
209
|
-
}
|
|
210
|
-
|
|
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
|
-
}
|
|
229
|
-
|
|
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}`))
|
|
191
|
+
const watcher = watchProject(root, async () => {
|
|
192
|
+
const result = await server.recompile()
|
|
193
|
+
if (!result.ok) {
|
|
194
|
+
warnErr(`Compile failed (devices keep the last good version):\n${result.error}`)
|
|
195
|
+
} else if (result.changed) {
|
|
196
|
+
const devices = server.clientCount()
|
|
197
|
+
note(`✓ v${result.v} → ${devices} device${devices === 1 ? "" : "s"}`)
|
|
198
|
+
for (const w of result.warnings) warnErr(w)
|
|
199
|
+
}
|
|
200
|
+
}, { warn: warnErr, debug: process.env.LECODES_DEV_DEBUG === "1" ? note : undefined })
|
|
237
201
|
|
|
238
202
|
// ---- banner ------------------------------------------------------------------------------------
|
|
239
203
|
log("")
|
|
@@ -270,13 +234,13 @@ export default defineCommand({
|
|
|
270
234
|
try {
|
|
271
235
|
session = launchOnAndroid({ port, url: androidUrl, serial: flags.serial, apk: flags.apk, packageName: flags.package })
|
|
272
236
|
} catch (e) {
|
|
273
|
-
server.close(); watcher
|
|
237
|
+
server.close(); watcher.close()
|
|
274
238
|
throw e
|
|
275
239
|
}
|
|
276
240
|
note(`Android: ${session.serial} → ${androidUrl} (adb reverse; device logs stream here as [device:log])`)
|
|
277
241
|
const finish = (code: number) => {
|
|
278
242
|
session.stop()
|
|
279
|
-
server.close(); watcher
|
|
243
|
+
server.close(); watcher.close()
|
|
280
244
|
process.exit(code)
|
|
281
245
|
}
|
|
282
246
|
process.on("SIGINT", () => { note("Stopping the app on the phone…"); finish(0) })
|
|
@@ -306,13 +270,13 @@ export default defineCommand({
|
|
|
306
270
|
try {
|
|
307
271
|
session = launchOnIos({ simulatorUrl: `http://127.0.0.1:${port}${path}`, deviceUrl, udid: flags.udid, app: flags.app, bundleId: flags.bundle })
|
|
308
272
|
} catch (e) {
|
|
309
|
-
server.close(); watcher
|
|
273
|
+
server.close(); watcher.close()
|
|
310
274
|
throw e
|
|
311
275
|
}
|
|
312
276
|
note(`iOS: ${session.name} → ${session.kind === "simulator" ? `http://127.0.0.1:${port}${path}` : deviceUrl} (device logs stream here as [device:log])`)
|
|
313
277
|
const finish = (code: number) => {
|
|
314
278
|
session.stop()
|
|
315
|
-
server.close(); watcher
|
|
279
|
+
server.close(); watcher.close()
|
|
316
280
|
process.exit(code)
|
|
317
281
|
}
|
|
318
282
|
process.on("SIGINT", () => { note(`Stopping the app on ${session.name}…`); finish(0) })
|
package/src/commands/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import status from "./status"
|
|
|
26
26
|
import test from "./test"
|
|
27
27
|
import thumbs from "./thumbs"
|
|
28
28
|
import update from "./update"
|
|
29
|
+
import web from "./web"
|
|
29
30
|
|
|
30
31
|
/*
|
|
31
32
|
* THE command list. Adding a command = one file with a `defineCommand` default export + one line
|
|
@@ -35,6 +36,7 @@ export const commandGroups = (opts: { install: boolean }): Group[] => [
|
|
|
35
36
|
{ title: "Project", commands: [init, create, clone, link, status, diff, pull, push, login] },
|
|
36
37
|
{ title: "Run & test", commands: [
|
|
37
38
|
dev,
|
|
39
|
+
web,
|
|
38
40
|
compile,
|
|
39
41
|
render,
|
|
40
42
|
test,
|
package/src/commands/test.ts
CHANGED
|
@@ -7,6 +7,9 @@ import { buildBundle } from "./compile"
|
|
|
7
7
|
import { findProjectRoot } from "../project/manifest"
|
|
8
8
|
import { runDesktopRender } from "../hosts/desktopRenderer"
|
|
9
9
|
import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
|
|
10
|
+
import { startLiteSession, type LiteSession } from "../hosts/liteRunner"
|
|
11
|
+
import { resolveLiteRunner } from "../dev/webRunner"
|
|
12
|
+
import { readManifest, type Manifest } from "../project/manifest"
|
|
10
13
|
import { CliError, bool, c, defineCommand, log, logErr, note, num, str, warnErr, type FlagValues } from "../cli"
|
|
11
14
|
import { bundleFlags, bundleOptions } from "./shared"
|
|
12
15
|
|
|
@@ -32,6 +35,10 @@ import { bundleFlags, bundleOptions } from "./shared"
|
|
|
32
35
|
* coordinates, expect.log + expect.paint checked, the semantic expects reported skipped). The
|
|
33
36
|
* design-derived flows need `expect.screen` and `fill`, which the host can't answer, so they stay
|
|
34
37
|
* headless-only. The viewport / timing and --desktop flags are `lecodes render`'s own groups.
|
|
38
|
+
*
|
|
39
|
+
* `--lite` runs them on WEB LITE in a browser (liteRunner.ts: headless Chromium, the page's own
|
|
40
|
+
* pointer and key events): the same steps, targets and expects, judged by the same rules as
|
|
41
|
+
* --desktop. The clock is the browser's — a step that expects a log line waits for it.
|
|
35
42
|
*/
|
|
36
43
|
|
|
37
44
|
type DesignEdge = { from: string, to: string, label?: string, activator?: string }
|
|
@@ -149,6 +156,7 @@ const testFlags = {
|
|
|
149
156
|
...viewportFlags,
|
|
150
157
|
...bundleFlags,
|
|
151
158
|
...desktopRenderFlags,
|
|
159
|
+
lite: bool("run the file scenarios on Web Lite, in a browser (needs Playwright): the same steps, targets and expects; the clock is the browser's, so a step that expects a log line waits for it; design flows stay headless"),
|
|
152
160
|
desktop: bool("run the file scenarios through the NATIVE host: the same steps, targets and expects as headless (expect.audio and the json output excepted); design flows stay headless. --frames / --fixed-dt as in render --desktop"),
|
|
153
161
|
}
|
|
154
162
|
// (`sim` comes with desktopRenderFlags: the file scenarios through the WINDOWLESS host — no GPU, no
|
|
@@ -169,6 +177,9 @@ export default defineCommand({
|
|
|
169
177
|
const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
170
178
|
// --sim is the native path on the windowless host: everything --desktop does, minus pixels.
|
|
171
179
|
const sim = flags.sim
|
|
180
|
+
const lite = flags.lite
|
|
181
|
+
if (lite && (flags.desktop || sim)) throw new CliError("--lite is a host of its own — drop --desktop / --sim.")
|
|
182
|
+
if (lite && flags.flows) throw new CliError("--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop --lite.")
|
|
172
183
|
const desktop = flags.desktop || sim
|
|
173
184
|
if (desktop && flags.flows) throw new CliError(`--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop --${sim ? "sim" : "desktop"}.`)
|
|
174
185
|
// --desktop: ONE renderer decision (host binary + the staged .filamat variant). The root is
|
|
@@ -177,14 +188,14 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
177
188
|
try { desktopRoot = findProjectRoot(process.cwd()) } catch { desktopRoot = process.cwd() }
|
|
178
189
|
const desktopRenderer = desktop ? resolveDesktopRenderer(desktopRoot, flags.renderer) : undefined
|
|
179
190
|
const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer }) : null
|
|
180
|
-
const headless = desktop ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
|
|
191
|
+
const headless = desktop || lite ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
|
|
181
192
|
const warnings = headless?.warnings ?? []
|
|
182
193
|
const useLocalAssets = headless?.useLocalAssets ?? false
|
|
183
194
|
const js = headless?.js ?? ""
|
|
184
195
|
let root: string
|
|
185
196
|
if (headless) root = headless.root
|
|
186
197
|
else { try { root = findProjectRoot(process.cwd()) } catch { root = process.cwd() } }
|
|
187
|
-
const renderer = desktop ? null : await requireRenderer()
|
|
198
|
+
const renderer = desktop || lite ? null : await requireRenderer()
|
|
188
199
|
const nativeFrames = flags.frames || 60
|
|
189
200
|
const nativeFixedDt = flags["fixed-dt"] ?? 16
|
|
190
201
|
if (nativeFixedDt < 0 || nativeFixedDt > 1000) throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${nativeFixedDt}".`)
|
|
@@ -226,10 +237,10 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
226
237
|
if (flowsOnly) cases.length = 0
|
|
227
238
|
let skipped: SkippedFlow[] = []
|
|
228
239
|
let entryScreen: string | null = null
|
|
229
|
-
if (desktop && wantFlows && existsSync(designMetaPath)) {
|
|
230
|
-
note("Design-derived flows skipped with --desktop (they read expect.screen and fill inputs; run `lecodes test` headless for them).")
|
|
240
|
+
if ((desktop || lite) && wantFlows && existsSync(designMetaPath)) {
|
|
241
|
+
note("Design-derived flows skipped with --desktop / --lite (they read expect.screen and fill inputs; run `lecodes test` headless for them).")
|
|
231
242
|
}
|
|
232
|
-
if (!desktop && (wantFlows || flowsOnly) && existsSync(designMetaPath)) {
|
|
243
|
+
if (!desktop && !lite && (wantFlows || flowsOnly) && existsSync(designMetaPath)) {
|
|
233
244
|
let meta: DesignMeta
|
|
234
245
|
try {
|
|
235
246
|
meta = JSON.parse(readFileSync(designMetaPath, "utf8")) as DesignMeta
|
|
@@ -286,7 +297,7 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
286
297
|
const reportCase = (result: CaseResult): void => {
|
|
287
298
|
const skippedCount = result.steps.reduce((n, s) => n + (s.skipped?.length ?? 0), 0)
|
|
288
299
|
if (result.ok) {
|
|
289
|
-
out(`${c.green("✓")} ${result.name}${skippedCount > 0 ? c.dim(` (${skippedCount}
|
|
300
|
+
out(`${c.green("✓")} ${result.name}${skippedCount > 0 ? c.dim(` (${skippedCount} check${skippedCount === 1 ? "" : "s"} skipped on this host)`) : ""}`)
|
|
290
301
|
} else {
|
|
291
302
|
out(`${c.red("✗")} ${result.name}`)
|
|
292
303
|
if (result.error) out(` ${c.red(result.error)}`)
|
|
@@ -324,8 +335,45 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
324
335
|
}
|
|
325
336
|
}
|
|
326
337
|
|
|
338
|
+
// Web Lite: one compile, one browser; a page of its own per case.
|
|
339
|
+
let liteSession: LiteSession | null = null
|
|
340
|
+
if (lite) {
|
|
341
|
+
const page = resolveLiteRunner(note)
|
|
342
|
+
if (!page) throw new CliError("No Web Lite page found: this CLI has no runtime/web-lite (a monorepo checkout needs `bun run build` in hosts/web-lite).")
|
|
343
|
+
let manifest: Manifest | null = null
|
|
344
|
+
try { manifest = readManifest(root) } catch { /* a folder standing alone */ }
|
|
345
|
+
liteSession = await startLiteSession({ root, manifest, name: manifest?.name ?? "app", lite: page, entry: flags.entry })
|
|
346
|
+
}
|
|
347
|
+
const runLiteCase = async (kase: FlowCase): Promise<CaseResult> => {
|
|
348
|
+
const caseDir = resolve(artifactsRoot, slug(kase.name))
|
|
349
|
+
const plan = translateScenario(kase.steps, { outDir: caseDir, headroomFrames: 0 })
|
|
350
|
+
mkdirSync(caseDir, { recursive: true })
|
|
351
|
+
const output = await liteSession!.run(plan, {
|
|
352
|
+
width: flags.width || kase.width || projectViewport?.width || 390,
|
|
353
|
+
height: flags.height || kase.height || projectViewport?.height || 844,
|
|
354
|
+
onConsole,
|
|
355
|
+
})
|
|
356
|
+
const r = evaluateDesktopRun(plan, output, (path) => existsSync(path) ? readFileSync(path, "utf8") : null)
|
|
357
|
+
return {
|
|
358
|
+
name: kase.name, kind: kase.kind, ok: r.ok,
|
|
359
|
+
steps: r.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error, skipped: s.skipped.length > 0 ? s.skipped : undefined })),
|
|
360
|
+
error: !r.loaded ? "the page did not load the app" : r.ended ? undefined : "the page stopped before the script finished",
|
|
361
|
+
artifacts: [],
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
327
365
|
for (const kase of cases) {
|
|
328
366
|
let result: CaseResult
|
|
367
|
+
if (lite) {
|
|
368
|
+
try {
|
|
369
|
+
result = await runLiteCase(kase)
|
|
370
|
+
} catch (e) {
|
|
371
|
+
result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] }
|
|
372
|
+
}
|
|
373
|
+
results.push(result)
|
|
374
|
+
reportCase(result)
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
329
377
|
if (desktop) {
|
|
330
378
|
try {
|
|
331
379
|
result = await runNativeCase(kase)
|
|
@@ -397,6 +445,7 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
397
445
|
}, null, 2) + "\n")
|
|
398
446
|
}
|
|
399
447
|
|
|
448
|
+
await liteSession?.close()
|
|
400
449
|
clearTimeout(killer)
|
|
401
450
|
process.exit(failed > 0 ? 1 : 0)
|
|
402
451
|
}
|
|
@@ -6,6 +6,7 @@ import pkg from "../../../package.json"
|
|
|
6
6
|
import { CliError, bool, c, defineCommand, log, note, readFlags, type Command } from "../../cli"
|
|
7
7
|
import { OPTIONAL_PEERS, peerSource, peerVersion } from "../../hosts/peers"
|
|
8
8
|
import { installPeers } from "../../hosts/peerInstall"
|
|
9
|
+
import { archiveSpec, archivedVersion } from "../../hosts/archives"
|
|
9
10
|
import { updateDesktop } from "../../hosts/desktopRenderer"
|
|
10
11
|
import { updateMatc } from "../../hosts/matcTool"
|
|
11
12
|
import { refreshTypes } from "./types"
|
|
@@ -24,7 +25,8 @@ import app from "../app"
|
|
|
24
25
|
* 6. cli the npm installs — every installed optional peer (lecodes-headless, lecodes-design,
|
|
25
26
|
* lecodes-3d-editor, lecodes-assets, @napi-rs/canvas) and the CLI itself that is behind:
|
|
26
27
|
* peers in ~/.lecodes/peers (first-use installs, peerInstall.ts) are re-installed there
|
|
27
|
-
* — the renderer at THIS CLI's version,
|
|
28
|
+
* — the renderer at THIS CLI's version, an editor at the newest one out that this
|
|
29
|
+
* CLI's peer range takes, the rest at npm's latest in range; global ones
|
|
28
30
|
* (`npm install -g`, the old way) via `npm install -g …@latest`; the CLI itself last
|
|
29
31
|
* (the standalone binary can't come from npm — it says so and does the peers only)
|
|
30
32
|
*
|
|
@@ -173,9 +175,10 @@ export default defineCommand({
|
|
|
173
175
|
for (const [name, probe] of OPTIONAL_PEERS) {
|
|
174
176
|
const where = peerSource(name, probe)
|
|
175
177
|
const installed = peerVersion(name, probe)
|
|
178
|
+
// an archived peer (the headless host, the editors) has no "latest" of a registry: the
|
|
179
|
+
// version this CLI installs is the one to have
|
|
176
180
|
const latest = !installed ? null
|
|
177
|
-
:
|
|
178
|
-
: await npmLatest(name)
|
|
181
|
+
: await archivedVersion(name) ?? await npmLatest(name)
|
|
179
182
|
rows.push({ name, installed, latest, where })
|
|
180
183
|
}
|
|
181
184
|
rows.push({ name: "lecodes-cli", installed: pkg.version, latest: await npmLatest("lecodes-cli"), where: "global" })
|
|
@@ -190,7 +193,7 @@ export default defineCommand({
|
|
|
190
193
|
}
|
|
191
194
|
if (behind.length === 0) return "ok"
|
|
192
195
|
const prefixBehind = behind.filter((r) => r.where === "prefix")
|
|
193
|
-
const globalSpecs = behind.filter((r) => r.where !== "prefix").map((r) => r.name + "@latest")
|
|
196
|
+
const globalSpecs = await Promise.all(behind.filter((r) => r.where !== "prefix").map(async (r) => await archiveSpec(r.name) ?? r.name + "@latest"))
|
|
194
197
|
if (flags.check) {
|
|
195
198
|
return ["updated", behind.length + " behind — run: lecodes update" + (globalSpecs.length ? " (global: npm install -g " + globalSpecs.join(" ") + ")" : "")]
|
|
196
199
|
}
|
|
@@ -207,7 +210,7 @@ export default defineCommand({
|
|
|
207
210
|
log(" " + c.dim("$") + " npm install -g " + toInstall.join(" "))
|
|
208
211
|
const r = spawnSync("npm", ["install", "-g", ...toInstall], { stdio: "inherit", shell: true })
|
|
209
212
|
if (r.status !== 0) throw new CliError("npm install -g exited with " + (r.status ?? "a signal"))
|
|
210
|
-
done.push(...
|
|
213
|
+
done.push(...behind.filter((r) => r.where !== "prefix" && (!STANDALONE || r.name !== "lecodes-cli")).map((r) => r.name))
|
|
211
214
|
}
|
|
212
215
|
if (done.length === 0) return ["skipped", "only the standalone CLI itself is behind — download the release"]
|
|
213
216
|
const cliToo = done.includes("lecodes-cli")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { basename, relative } from "node:path"
|
|
2
|
+
import { findProjectRoot, readManifest, type Manifest } from "../project/manifest"
|
|
3
|
+
import { startDevServer } from "../dev/devServer"
|
|
4
|
+
import { resolveLiteRunner } from "../dev/webRunner"
|
|
5
|
+
import { watchProject } from "../dev/watchProject"
|
|
6
|
+
import { openBrowser } from "../platform/browserAuth"
|
|
7
|
+
import { CliError, bool, c, defineCommand, log, note, num, str, warnErr } from "../cli"
|
|
8
|
+
import { buildWeb } from "../web/build"
|
|
9
|
+
import { startPreview } from "../web/preview"
|
|
10
|
+
import { bundleFlags } from "./shared"
|
|
11
|
+
import { lanCandidates, loadDevToken } from "./dev"
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* `lecodes web` — the project as a WEB PAGE (Web Lite: DOM and CSS, the browser's layout, text and
|
|
15
|
+
* scroll — docs/plans/web-lite-plan.md). Not the web player of `lecodes dev --web`, which runs
|
|
16
|
+
* the app on the runtime as wasm and paints it to a canvas: that one is how the app looks on a
|
|
17
|
+
* phone, this one is what a site ships.
|
|
18
|
+
*
|
|
19
|
+
* dev the page, served from this machine and reloaded on every save
|
|
20
|
+
* build a folder any static hosting serves (web/build.ts)
|
|
21
|
+
* preview serve a built folder, as a hosting would
|
|
22
|
+
*
|
|
23
|
+
* Fully local: no login, no le.codes backend.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const DEFAULT_PORT = 4488
|
|
27
|
+
const DEFAULT_OUT = "dist/web"
|
|
28
|
+
|
|
29
|
+
const project = (): { root: string, manifest: Manifest | null, name: string } => {
|
|
30
|
+
let root: string
|
|
31
|
+
let manifest: Manifest | null
|
|
32
|
+
try {
|
|
33
|
+
root = findProjectRoot(process.cwd())
|
|
34
|
+
manifest = readManifest(root)
|
|
35
|
+
} catch {
|
|
36
|
+
root = process.cwd()
|
|
37
|
+
manifest = null
|
|
38
|
+
}
|
|
39
|
+
return { root, manifest, name: manifest?.name ?? basename(root) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const litePage = (): string => {
|
|
43
|
+
const lite = resolveLiteRunner(note)
|
|
44
|
+
if (!lite) throw new CliError("No Web Lite page found: this CLI has no runtime/web-lite (a monorepo checkout needs `bun run build` in hosts/web-lite).")
|
|
45
|
+
return lite
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const kb = (bytes: number): string => bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
|
49
|
+
|
|
50
|
+
const dev = defineCommand({
|
|
51
|
+
name: "dev",
|
|
52
|
+
summary: "Open the project as a web page, reloaded on every save",
|
|
53
|
+
description: "Serves the project's bundle and assets with the page of Web Lite around them; a save recompiles and runs the app again, the page's console output and errors stream back here. The address's hash is the route the app is opened with (#/product/42 → app.launchUrl).",
|
|
54
|
+
flags: {
|
|
55
|
+
port: num("server port", { default: DEFAULT_PORT }),
|
|
56
|
+
entry: bundleFlags.entry,
|
|
57
|
+
"no-open": bool("don't open the browser"),
|
|
58
|
+
},
|
|
59
|
+
examples: ["lecodes web dev", "lecodes web dev --port 8080 --no-open"],
|
|
60
|
+
run: async ({ flags }) => {
|
|
61
|
+
const { root, manifest, name } = project()
|
|
62
|
+
const lite = litePage()
|
|
63
|
+
|
|
64
|
+
const port = flags.port || DEFAULT_PORT
|
|
65
|
+
// The page is opened on this machine; a phone on the same network reaches it by the LAN address.
|
|
66
|
+
const lan = lanCandidates()[0]?.address
|
|
67
|
+
const origin = `http://localhost:${port}`
|
|
68
|
+
const server = await startDevServer({
|
|
69
|
+
root, manifest, name, origin, port,
|
|
70
|
+
token: loadDevToken(root),
|
|
71
|
+
entryOverride: flags.entry,
|
|
72
|
+
lite,
|
|
73
|
+
log: note,
|
|
74
|
+
}).catch((e: unknown) => {
|
|
75
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
76
|
+
throw new CliError(msg.includes("EADDRINUSE") ? `Port ${port} is already in use — pass --port <n>.` : msg)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
note("Compiling…")
|
|
80
|
+
const first = await server.recompile()
|
|
81
|
+
if (first.ok) for (const w of first.warnings) warnErr(w)
|
|
82
|
+
else warnErr(`Compile failed — fix it and save; the page loads once it compiles:\n${first.error}`)
|
|
83
|
+
|
|
84
|
+
const watcher = watchProject(root, async () => {
|
|
85
|
+
const result = await server.recompile()
|
|
86
|
+
if (!result.ok) warnErr(`Compile failed (the page keeps the last good version):\n${result.error}`)
|
|
87
|
+
else if (result.changed) {
|
|
88
|
+
note(`✓ v${result.v}`)
|
|
89
|
+
for (const w of result.warnings) warnErr(w)
|
|
90
|
+
}
|
|
91
|
+
}, { warn: warnErr, debug: process.env.LECODES_DEV_DEBUG === "1" ? note : undefined })
|
|
92
|
+
|
|
93
|
+
const url = server.liteUrl!
|
|
94
|
+
log("")
|
|
95
|
+
log(`${c.bold("LeCodes Web")} — ${name}`)
|
|
96
|
+
log("")
|
|
97
|
+
log(`In the browser: ${c.bold(url)}`)
|
|
98
|
+
if (lan) note(`On this network: ${url.replace("http://localhost:", `http://${lan}:`)}`)
|
|
99
|
+
log("")
|
|
100
|
+
log(`Watching ${root} — save a file to reload. Ctrl+C to stop.`)
|
|
101
|
+
|
|
102
|
+
if (!flags["no-open"]) openBrowser(url)
|
|
103
|
+
|
|
104
|
+
process.on("SIGINT", () => {
|
|
105
|
+
watcher.close()
|
|
106
|
+
server.close()
|
|
107
|
+
process.exit(0)
|
|
108
|
+
})
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const build = defineCommand({
|
|
113
|
+
name: "build",
|
|
114
|
+
summary: "Build the project into a folder any static hosting serves",
|
|
115
|
+
description: "Writes the page, the bundle, the page's own files and the assets the bundle names into one folder (dist/web). Every url in it is relative: the folder works at a domain's root and inside a folder of one.\n\napp.json `web` sets the page: `title`, `themeColor`, `lang`, and `urls` — \"hash\" (default: a route is `#/product/42`, which any hosting serves) or \"path\" (`/product/42` under `base`: the hosting has to answer every path with the page; a 404.html is written for the ones that do it that way).",
|
|
116
|
+
flags: {
|
|
117
|
+
out: str("the folder to write (cleaned first)", { alias: "o", value: "<dir>", default: DEFAULT_OUT }),
|
|
118
|
+
entry: bundleFlags.entry,
|
|
119
|
+
sourcemap: bool("inline the source map (errors name the app's own lines; the bundle grows by its sources)"),
|
|
120
|
+
"no-minify": bundleFlags["no-minify"],
|
|
121
|
+
},
|
|
122
|
+
examples: ["lecodes web build", "lecodes web build -o public"],
|
|
123
|
+
run: async ({ flags }) => {
|
|
124
|
+
const { root, manifest, name } = project()
|
|
125
|
+
const lite = litePage()
|
|
126
|
+
note("Building…")
|
|
127
|
+
const result = await buildWeb({
|
|
128
|
+
root, manifest, name, lite, out: flags.out, entry: flags.entry,
|
|
129
|
+
sourcemap: flags.sourcemap, minify: flags["no-minify"] ? false : undefined,
|
|
130
|
+
})
|
|
131
|
+
for (const w of result.warnings) warnErr(w)
|
|
132
|
+
const total = result.files.reduce((sum, f) => sum + f.bytes, 0)
|
|
133
|
+
const shown = relative(process.cwd(), result.out) || "."
|
|
134
|
+
log("")
|
|
135
|
+
for (const f of [...result.files].sort((a, b) => b.bytes - a.bytes).slice(0, 8)) log(` ${kb(f.bytes).padStart(10)} ${f.path}`)
|
|
136
|
+
if (result.files.length > 8) log(c.dim(` …and ${result.files.length - 8} more`))
|
|
137
|
+
log("")
|
|
138
|
+
log(`${c.green("✓")} ${c.bold(shown)} — ${result.files.length} files, ${kb(total)}`)
|
|
139
|
+
note(`Look at it: lecodes web preview${flags.out === DEFAULT_OUT ? "" : ` ${flags.out}`}`)
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const preview = defineCommand({
|
|
144
|
+
name: "preview",
|
|
145
|
+
summary: "Serve a built folder, as a static hosting would",
|
|
146
|
+
usage: "[dir]",
|
|
147
|
+
flags: {
|
|
148
|
+
port: num("server port", { default: DEFAULT_PORT + 1 }),
|
|
149
|
+
"no-open": bool("don't open the browser"),
|
|
150
|
+
},
|
|
151
|
+
examples: ["lecodes web preview", "lecodes web preview public --port 8080"],
|
|
152
|
+
run: async ({ args, flags }) => {
|
|
153
|
+
const dir = args[0] ?? DEFAULT_OUT
|
|
154
|
+
const server = await startPreview(dir, flags.port || DEFAULT_PORT + 1).catch((e: unknown) => {
|
|
155
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
156
|
+
throw new CliError(msg.includes("EADDRINUSE") ? `Port ${flags.port} is already in use — pass --port <n>.` : msg)
|
|
157
|
+
})
|
|
158
|
+
log(`${c.bold("LeCodes Web")} — ${dir}`)
|
|
159
|
+
log(`In the browser: ${c.bold(server.url)}`)
|
|
160
|
+
log("Ctrl+C to stop.")
|
|
161
|
+
if (!flags["no-open"]) openBrowser(server.url)
|
|
162
|
+
process.on("SIGINT", () => { server.close(); process.exit(0) })
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
export default defineCommand({
|
|
167
|
+
name: "web",
|
|
168
|
+
summary: "The project as a web page (Web Lite): serve it with reload on save, build it for a hosting",
|
|
169
|
+
description: "Web Lite shows the app with the browser's own layout, text and scroll: what a site ships. UI apps for now — a project's 3D and 2D scenes are not shown yet.",
|
|
170
|
+
commands: [dev, build, preview],
|
|
171
|
+
})
|
package/src/compile/fonts.ts
CHANGED
|
@@ -37,6 +37,9 @@ export type ProjectFontsOptions = {
|
|
|
37
37
|
registry?: string
|
|
38
38
|
/** Absolute-URL prefix of the compile (URL modes) — used for the header's entry-face URLs. */
|
|
39
39
|
publicUrl?: string
|
|
40
|
+
/** The compile's urls are RELATIVE to the page that runs the bundle (a static web build): the
|
|
41
|
+
* header's urls are written as they are, with no origin before them. */
|
|
42
|
+
relative?: boolean
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
export const applyProjectFonts = async (entries: CompileEntry[], opts: ProjectFontsOptions): Promise<string | undefined> => {
|
|
@@ -68,9 +71,9 @@ export const applyProjectFonts = async (entries: CompileEntry[], opts: ProjectFo
|
|
|
68
71
|
if (faces.length === 0) return undefined
|
|
69
72
|
|
|
70
73
|
return buildFontsHeaderValue(faces, (path) => {
|
|
71
|
-
if (!opts.publicUrl || !/^https?:/i.test(opts.publicUrl)) return null
|
|
74
|
+
if (!opts.relative && (!opts.publicUrl || !/^https?:/i.test(opts.publicUrl))) return null
|
|
72
75
|
const fileSrc = byPath().get(path.replace(/^\//, ""))?.fileSrc
|
|
73
|
-
return fileSrc ? opts.publicUrl + fileSrc : null
|
|
76
|
+
return fileSrc ? (opts.publicUrl ?? "") + fileSrc : null
|
|
74
77
|
}) || undefined
|
|
75
78
|
}
|
|
76
79
|
|
package/src/dev/devServer.ts
CHANGED
|
@@ -31,6 +31,8 @@ import type { WebRunner } from "./webRunner"
|
|
|
31
31
|
* player/?dev=../.. — it loads bundle.js?t=web, the bundle WITHOUT
|
|
32
32
|
* the native prelude; the player owns reload (ws / version
|
|
33
33
|
* polling) and the log backchannel. A phone is sent here at once
|
|
34
|
+
* GET /t/<tok>/lite/[file] the page of Web Lite (lecodes-web-lite's dist), opened as
|
|
35
|
+
* lite/?dev=.. — the same bundle.js?t=web, the same reload and logs
|
|
34
36
|
*
|
|
35
37
|
* Compile is eager: the command calls recompile() on every file change; a successful compile bumps
|
|
36
38
|
* `v` and broadcasts {type:"reload", v} to connected sockets (polling clients see /version move).
|
|
@@ -51,6 +53,8 @@ export type DevServerOptions = {
|
|
|
51
53
|
/** What is served under /web/ (src/dev/webRunner.ts): the player, and the page around it; absent =
|
|
52
54
|
* no browser route. */
|
|
53
55
|
web?: WebRunner | null
|
|
56
|
+
/** The page of Web Lite (lecodes-web-lite's dist), served under /lite/; absent = no such route. */
|
|
57
|
+
lite?: string | null
|
|
54
58
|
/** Diagnostics (compile errors, device logs); defaults to stderr-style silence-safe noop. */
|
|
55
59
|
log?: (msg: string) => void
|
|
56
60
|
}
|
|
@@ -63,6 +67,8 @@ export type DevServer = {
|
|
|
63
67
|
url: string
|
|
64
68
|
/** The browser runner page (`dev --web`), or null when no runner is vendored. */
|
|
65
69
|
webUrl: string | null
|
|
70
|
+
/** The page of Web Lite (`lecodes web dev`), or null when none is served. */
|
|
71
|
+
liteUrl: string | null
|
|
66
72
|
/** The token base path ("/t/<token>"). */
|
|
67
73
|
base: string
|
|
68
74
|
/** The actually-bound port (differs from options.port when 0 was passed, e.g. in tests). */
|
|
@@ -84,7 +90,7 @@ const MIME: Record<string, string> = {
|
|
|
84
90
|
css: "text/css", html: "text/html", wasm: "application/wasm", map: "application/json",
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
const contentType = (path: string): string => {
|
|
93
|
+
export const contentType = (path: string): string => {
|
|
88
94
|
const i = path.lastIndexOf(".")
|
|
89
95
|
return (i >= 0 && MIME[path.slice(i + 1).toLowerCase()]) || "application/octet-stream"
|
|
90
96
|
}
|
|
@@ -233,6 +239,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
|
|
|
233
239
|
return transport === "web" ? js : js + "\n" + renderPrelude({ origin: reqOrigin, base, transport, v })
|
|
234
240
|
}
|
|
235
241
|
const web = options.web ?? null
|
|
242
|
+
const lite = options.lite ?? null
|
|
236
243
|
/** A file of a built folder; the entry files revalidate — a CLI update swaps them under the same
|
|
237
244
|
* url — while what is under assets/ carries its hash in its name. */
|
|
238
245
|
const sendBuilt = (res: ServerResponse, dir: string, rel: string) => {
|
|
@@ -284,6 +291,17 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
|
|
|
284
291
|
if (rel === "") return redirect(`${base}/web/${query()}`)
|
|
285
292
|
return sendBuilt(res, web.page, rel === "/" ? "index.html" : rel.slice(1))
|
|
286
293
|
}
|
|
294
|
+
if (route === "/lite" || route.startsWith("/lite/")) {
|
|
295
|
+
if (!lite) return sendText(res, 404, "text/plain", "No Web Lite page with this CLI (lecodes-web-lite's dist).")
|
|
296
|
+
const rel = route.slice("/lite".length)
|
|
297
|
+
if (rel === "" || (rel === "/" && !url.searchParams.has("dev"))) {
|
|
298
|
+
url.searchParams.set("dev", "..")
|
|
299
|
+
res.writeHead(302, { location: `${base}/lite/?${url.searchParams.toString()}${url.hash}` })
|
|
300
|
+
res.end()
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
return sendBuilt(res, lite, rel === "/" ? "index.html" : rel.slice(1))
|
|
304
|
+
}
|
|
287
305
|
if (route === "/version") {
|
|
288
306
|
return sendJson(res, 200, { v })
|
|
289
307
|
}
|
|
@@ -363,6 +381,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
|
|
|
363
381
|
return {
|
|
364
382
|
url: `${origin}${base}/dev.js`,
|
|
365
383
|
webUrl: web ? `${origin}${base}/web/` : null,
|
|
384
|
+
liteUrl: lite ? `${origin}${base}/lite/` : null,
|
|
366
385
|
base,
|
|
367
386
|
port: boundPort,
|
|
368
387
|
v: () => v,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from "node:fs"
|
|
2
|
+
import { loadIgnore } from "../project/ignore"
|
|
3
|
+
import { toKey } from "../project/paths"
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* The watcher of a dev command: a change of the project's content calls `onChange`, debounced — a
|
|
7
|
+
* save of several files is one recompile. What never counts: the CLI's own folders, the editors'
|
|
8
|
+
* files, what the project ignores.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEBOUNCE_MS = 120
|
|
12
|
+
|
|
13
|
+
// Never trigger a recompile (mirrors scanLocal's skip lists + our own artifacts).
|
|
14
|
+
const SKIP_DIRS = new Set([".lecodes", ".git", "node_modules", "dist"])
|
|
15
|
+
const SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"])
|
|
16
|
+
|
|
17
|
+
export type ProjectWatcher = { close(): void }
|
|
18
|
+
|
|
19
|
+
export const watchProject = (root: string, onChange: () => void, log: { warn(message: string): void, debug?(message: string): void }): ProjectWatcher => {
|
|
20
|
+
const ignore = loadIgnore(root)
|
|
21
|
+
const counts = (file: string | null): boolean => {
|
|
22
|
+
if (file === null) return true // an event without a name: assume the project's content
|
|
23
|
+
const parts = file.replace(/\\/g, "/").split("/")
|
|
24
|
+
if (parts.some((p) => SKIP_DIRS.has(p))) return false
|
|
25
|
+
if (SKIP_FILES.has(parts[parts.length - 1]!)) return false
|
|
26
|
+
try {
|
|
27
|
+
if (ignore(toKey(file), false)) return false
|
|
28
|
+
} catch { return false }
|
|
29
|
+
return true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let timer: NodeJS.Timeout | null = null
|
|
33
|
+
let watcher: FSWatcher | null = null
|
|
34
|
+
try {
|
|
35
|
+
watcher = watch(root, { recursive: true }, (_type, file) => {
|
|
36
|
+
const name = file === null ? null : String(file)
|
|
37
|
+
log.debug?.(`[watch] event: ${name ?? "(null)"} → ${counts(name) ? "recompile" : "skip"}`)
|
|
38
|
+
if (!counts(name)) return
|
|
39
|
+
if (timer) clearTimeout(timer)
|
|
40
|
+
timer = setTimeout(() => { timer = null; onChange() }, DEBOUNCE_MS)
|
|
41
|
+
})
|
|
42
|
+
} catch {
|
|
43
|
+
log.warn("fs.watch(recursive) unavailable on this platform — hot reload disabled (restart to pick up changes).")
|
|
44
|
+
}
|
|
45
|
+
watcher?.on("error", (e) => log.warn(`Watcher error — hot reload may have stopped (restart the command): ${e.message}`))
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
close() {
|
|
49
|
+
if (timer) clearTimeout(timer)
|
|
50
|
+
watcher?.close()
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|