lecodes-cli 2.0.7 → 2.0.9

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 (38) hide show
  1. package/dist/index.js +1798 -1116
  2. package/package.json +21 -11
  3. package/runtime/sdk-types.json +1 -1
  4. package/runtime/web/VERSION.json +3 -3
  5. package/runtime/web/assets/creator-2d-BSJoqaE1.wasm +0 -0
  6. package/runtime/web/assets/{creator-2d-CYlV77UR.js → creator-2d-DG2jJnoQ.js} +1 -1
  7. package/runtime/web/assets/creator-ui-Bu2prHDl.wasm +0 -0
  8. package/runtime/web/assets/creator-ui-HDloYDcM.js +1 -0
  9. package/runtime/web/player.js +15 -15
  10. package/runtime/web-lite/assets/pager-DpGa2uaE.js +1 -0
  11. package/runtime/web-lite/assets/vlist-CwSYpLz-.js +1 -0
  12. package/runtime/web-lite/features.json +2 -2
  13. package/runtime/web-lite/lite.js +21 -10
  14. package/src/commands/app/android.ts +7 -7
  15. package/src/commands/app/index.ts +58 -84
  16. package/src/commands/app/shared.ts +53 -74
  17. package/src/commands/app/templates/ios.ts +75 -78
  18. package/src/commands/compile.ts +1 -1
  19. package/src/commands/init/templates.ts +7 -0
  20. package/src/commands/render.ts +17 -4
  21. package/src/commands/test.ts +10 -6
  22. package/src/compile/collect.ts +12 -3
  23. package/src/compile/headlessBundle.ts +10 -3
  24. package/src/declarations/lecodes-headless.d.ts +3 -0
  25. package/src/dev/backendWorker.ts +17 -6
  26. package/src/dev/localBackend.ts +8 -2
  27. package/src/dev/projectBackend.ts +31 -9
  28. package/src/hosts/desktopScript.ts +11 -1
  29. package/src/hosts/flowPick.ts +111 -0
  30. package/src/hosts/liteBrowser.mjs +5 -0
  31. package/src/hosts/liteRunner.ts +30 -0
  32. package/src/hosts/peerInstall.ts +5 -1
  33. package/src/hosts/peers.ts +2 -0
  34. package/runtime/web/assets/creator-2d-nG7rGEmq.wasm +0 -0
  35. package/runtime/web/assets/creator-ui-CbtoaRlz.js +0 -1
  36. package/runtime/web/assets/creator-ui-DZaoxIpX.wasm +0 -0
  37. package/runtime/web-lite/assets/pager-Db5BY7FB.js +0 -1
  38. package/runtime/web-lite/assets/vlist-DrV2Q7P5.js +0 -1
@@ -482,48 +482,19 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
482
482
 
483
483
  export const VIEW_CONTROLLER = `//
484
484
  // ViewController.swift
485
- // Generated by lecodes app init — yours to edit.
485
+ // Generated by lecodes app init — yours to edit; \`lecodes app sync\` never touches it.
486
486
  //
487
- // Runs the bundled app.js (produced by \`lecodes app sync\`) offline: everything the
488
- // script needs is provided by the SDK core (_creatorTree / the host bridges / console)
489
- // plus the plugins registered below via LeCodesRuntime.
487
+ // The shell's controller: LeCodesAppViewController (LeCodesRuntime, CLI-owned) boots the engine
488
+ // with the bundled app.js — offline, the plugins of app.json registered, newer bundles taken over
489
+ // the air (app.json "update"). Override \`configure(_:)\` to register your own native code
490
+ // (\`engine.registerService\`, \`engine.registerView\`) before the bundle runs, or any UIViewController
491
+ // hook to put native UI around the app's root view.
490
492
  //
491
493
 
492
494
  import UIKit
493
495
  import LeCodesRuntime
494
496
 
495
- class ViewController: LeCodesViewController {
496
-
497
- override func viewDidLoad() {
498
- super.viewDidLoad()
499
-
500
- let lang = Locale.preferredLanguages.first.map { String($0.prefix(2)) } ?? "en"
501
- engine.setLanguage(lang)
502
-
503
- // \`// preload:\` assets (images, fonts) resolve out of the app bundle.
504
- engine.setFetchLocal { path in
505
- let ns = path as NSString
506
- let ext = ns.pathExtension
507
- let name = ns.deletingPathExtension
508
- guard let url = Bundle.main.url(forResource: name, withExtension: ext.isEmpty ? nil : ext) else {
509
- return nil
510
- }
511
- return try? Data(contentsOf: url)
512
- }
513
-
514
- registerBundledPlugins(in: engine)
515
-
516
- // OTA (native, no JS involved): start the check FIRST — even a bundle that crashes
517
- // on boot can never block its own fix — then run the newest local bundle (a
518
- // downloaded update, or the embedded app.js). New code applies on the next launch.
519
- // Configured via app.json "update" (→ Info.plist LeCodesUpdateURL); offline: no-op.
520
- LeCodesUpdater.checkForUpdate()
521
- guard let code = LeCodesUpdater.activeBundle() else {
522
- print("LeCodes: bundled app.js is missing — run \`lecodes app sync\`")
523
- return
524
- }
525
- engine.run(code)
526
- }
497
+ class ViewController: LeCodesAppViewController {
527
498
  }
528
499
  `
529
500
 
@@ -702,19 +673,32 @@ export interface RuntimePlugin {
702
673
  packages?: IosPackage[]
703
674
  }
704
675
 
705
- /** The SDK binary products (all vend the same `LeCodesSDK` module): core = UI only,
706
- * 2d = + Creator2D, 3d = + CreatorGL/filament with Jolt physics (AR included), full = everything. */
676
+ /** The SDK's engine variants — one tag of lecodes-ios-sdk per variant (`<version>` = full,
677
+ * `<version>-3d`, `-2d`, `-core`): core = UI only, 2d = + the 2D engine, 3d = + Filament with Jolt
678
+ * (AR included), full = everything. The Swift sources are the same; the engine binary differs. */
707
679
  export type SdkVariant = "core" | "2d" | "3d" | "full"
708
680
 
709
- /** LeCodesRuntime/Package.swift — the CLI-owned dependency graph: the SDK variant + one
710
- * vendored target per installed plugin.
711
- *
712
- * `sdkVersion` is always an exact release and is pinned with `.exact(…)`: the CLI also
713
- * downloads that version's LeCodesSDKResources.bundle (filament assets) into App/Resources/,
714
- * so letting SwiftPM drift to a newer release on its own would pair mismatched halves of one
715
- * SDK. Moving the version is the CLI's job — `lecodes app update` (or app.json ios.sdk). */
716
- export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant, plugins: RuntimePlugin[]): string => {
717
- const product = `LeCodesSDK-${variant}`
681
+ /** The tag of lecodes-ios-sdk a shell pins for an SDK version + variant. */
682
+ export const sdkTag = (sdkVersion: string, variant: SdkVariant): string => variant === "full" ? sdkVersion : `${sdkVersion}-${variant}`
683
+
684
+ /** The SDK products a shell links: `LeCodes` always; the 3D variants also AR (ARKit behind the
685
+ * scenes) and the binaural filters (`engine.useHrtf()`), as the viewer does. */
686
+ export const sdkProducts = (variant: SdkVariant): string[] =>
687
+ variant === "full" || variant === "3d" ? ["LeCodes", "LeCodesAR", "LeCodesHRTF"] : ["LeCodes"]
688
+
689
+ /** LeCodesRuntime/Package.swift — the CLI-owned dependency graph: the SDK (one tag per
690
+ * variant, pinned with `.exact(…)`: the variant is the engine binary the tag's manifest names,
691
+ * so SwiftPM must never drift on its own — moving the version is the CLI's job, `lecodes app
692
+ * update` or app.json ios.sdk) + one vendored target per installed plugin. The SDK's assets
693
+ * (the 3D variants' shader archives) ride inside the package. */
694
+ export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant, plugins: RuntimePlugin[], sdkPath?: string): string => {
695
+ const sdkDep = (name: string) => `.product(name: "${name}", package: "lecodes-ios-sdk")`
696
+ // LECODES_IOS_SDK_PATH: the SDK-development loop — a package tree on disk (release-ios-sdk.sh
697
+ // --check assembles one per variant) in place of the published tag; `name:` keeps the products'
698
+ // package identity so nothing else in this manifest changes.
699
+ const sdkPackage = sdkPath
700
+ ? `.package(name: "lecodes-ios-sdk", path: "${sdkPath}"), // LECODES_IOS_SDK_PATH — not a release`
701
+ : `.package(url: "https://github.com/letary/lecodes-ios-sdk.git", exact: "${sdkTag(sdkVersion, variant)}"),`
718
702
  // Third-party Swift packages (manifest ios.packages), one `.package(…)` per url. Two plugins
719
703
  // naming the same url must agree on the pin — SwiftPM has one resolution per package.
720
704
  const packages = new Map<string, IosPackage>()
@@ -731,7 +715,7 @@ export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant,
731
715
  `\n .package(url: "${pkg.url}", ${pkg.exact ? `exact: "${pkg.exact}"` : `from: "${pkg.from}"`}),`).join("")
732
716
  const pluginTargets = plugins.map(p => {
733
717
  const deps = [
734
- `.product(name: "${product}", package: "lecodes-ios-sdk")`,
718
+ sdkDep("LeCodes"),
735
719
  ...(p.packages ?? []).flatMap(pkg => pkg.products.map(name => `.product(name: "${name}", package: "${swiftPackageName(pkg.url)}")`)),
736
720
  ]
737
721
  return `
@@ -742,14 +726,14 @@ export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant,
742
726
  path: "Sources/${p.module}"
743
727
  ),`
744
728
  }).join("")
745
- const runtimeDeps = [`.product(name: "${product}", package: "lecodes-ios-sdk")`, ...plugins.map(p => `"${p.module}"`)]
729
+ const runtimeDeps = [...sdkProducts(variant).map(sdkDep), ...plugins.map(p => `"${p.module}"`)]
746
730
  return `// swift-tools-version:5.9
747
731
  //
748
732
  // GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
749
- // Owns the app's native dependency graph: the LeCodesSDK variant (auto-picked from what
750
- // the compiled bundle uses, or forced via app.json ios.variant), the exact SDK release
751
- // (app.json ios.sdk — move it with \`lecodes app update\`) and one vendored target per
752
- // installed plugin (see plugins.lock.json).
733
+ // Owns the app's native dependency graph: the SDK's tag (the release of app.json ios.sdk — move
734
+ // it with \`lecodes app update\` — and the engine variant, auto-picked from what the compiled
735
+ // bundle uses or forced via app.json ios.variant) and one vendored target per installed plugin
736
+ // (see plugins.lock.json).
753
737
 
754
738
  import PackageDescription
755
739
 
@@ -760,7 +744,7 @@ let package = Package(
760
744
  .library(name: "LeCodesRuntime", targets: ["LeCodesRuntime"]),
761
745
  ],
762
746
  dependencies: [
763
- .package(url: "https://github.com/letary/lecodes-ios-sdk.git", exact: "${sdkVersion}"),${packageDecls}
747
+ ${sdkPackage}${packageDecls}
764
748
  ],
765
749
  targets: [
766
750
  .target(
@@ -775,31 +759,43 @@ let package = Package(
775
759
  `
776
760
  }
777
761
 
778
- /** Sources/LeCodesRuntime/LeCodesRuntime.swift — SDK re-export + plugin registration +
779
- * (server-backed projects, SDK ≥ 1.6.0) the baked project identity. Baking it here — in a
780
- * CLI-owned file that runs BEFORE \`engine.run(code)\` — means every shell picks identity up
781
- * from a plain \`lecodes app sync\`, with no edits to the user-owned ViewController. */
782
- export const runtimeSwiftTemplate = (plugins: RuntimePlugin[], projectUuid?: string): string => {
783
- const imports = plugins.map(p => `import ${p.module}\n`).join("")
784
- const identity = !projectUuid ? "" : ` // World identity: this shell IS the project — the engine attributes the boot world so
785
- // identity-gated services (push registration) see the app's uuid from its first
786
- // synchronous statement. Baked from .lecodes/manifest.json by \`lecodes app sync\`.
787
- engine.bootProjectUuid = "${projectUuid}"
762
+ /** Sources/LeCodesRuntime/LeCodesRuntime.swift — the SDK re-exported (app code needs only
763
+ * \`import LeCodesRuntime\`) and the shell's controller: LeCodesAppViewController, a
764
+ * LeCodesShellViewController (the SDK's — the boot: language, the OTA check, the newest local
765
+ * bundle) whose \`configure\` registers every plugin of app.json, the AR kinds of a 3D variant and
766
+ * (server-backed projects) the baked project identity — all BEFORE the bundle runs, in a
767
+ * CLI-owned file: a plain \`lecodes app sync\` moves every shell, the user-owned ViewController
768
+ * (a subclass of this one) is never edited. */
769
+ export const runtimeSwiftTemplate = (plugins: RuntimePlugin[], projectUuid: string | undefined, variant: SdkVariant): string => {
770
+ const is3d = variant === "full" || variant === "3d"
771
+ const imports = [...(is3d ? ["import LeCodesAR\n", "import LeCodesHRTF\n"] : []), ...plugins.map(p => `import ${p.module}\n`)].join("")
772
+ const identity = !projectUuid ? "" : ` // World identity: this shell IS the project — the engine attributes the boot world so
773
+ // identity-gated services (push registration) see the app's uuid from its first
774
+ // synchronous statement. Baked from .lecodes/manifest.json by \`lecodes app sync\`.
775
+ engine.bootProjectUuid = "${projectUuid}"
788
776
  `
789
- const calls = plugins.map(p => ` ${p.register}.register(in: engine)\n`).join("")
790
- const body = identity + calls
777
+ const ar = !is3d ? "" : ` // The AR controller kinds of the 3D engine (ARKit), and the binaural audio filters.
778
+ for mode in ["default", "world", "arcore", "detached"] { engine.registerARController(mode) { ARKitController(.world) } }
779
+ engine.registerARController("markers") { ARKitController(.markers) }
780
+ engine.useHrtf()
781
+ `
782
+ const calls = plugins.map(p => ` ${p.register}.register(in: engine)\n`).join("")
783
+ const body = identity + ar + calls
791
784
  return `//
792
785
  // LeCodesRuntime.swift
793
786
  // GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
794
787
  //
795
- // Re-exports the SDK (so app code needs only \`import LeCodesRuntime\`) and registers
796
- // every plugin installed in app.json.
788
+ // Re-exports the SDK (so app code needs only \`import LeCodesRuntime\`) and gives the app its
789
+ // controller: the SDK's shell controller with this project's plugins, AR kinds and identity
790
+ // registered before the bundle runs. App/ViewController.swift subclasses it.
797
791
  //
798
792
 
799
- @_exported import LeCodesSDK
793
+ @_exported import LeCodes
800
794
  ${imports}
801
- public func registerBundledPlugins(in engine: LeCodesEngine) {
802
- ${body === "" ? "\n" : body}}
795
+ open class LeCodesAppViewController: LeCodesShellViewController {
796
+ open override func configure(_ engine: LeCodesEngine) {
797
+ ${body === "" ? " // no plugins installed (app.json \"plugins\")\n" : body} }
798
+ }
803
799
  `
804
800
  }
805
801
 
@@ -859,18 +855,19 @@ CLI-owned — regenerated on every sync, hand edits are overwritten:
859
855
  assets (videos, big models) back into streaming from the server instead.
860
856
  - \`App/Info.plist\` — template + the installed plugins' usage keys
861
857
  - \`App/App.entitlements\` — the capabilities the installed plugins declare (e.g. push)
862
- - \`LeCodesRuntime/\` — local Swift package: the LeCodesSDK dependency, vendored plugin
863
- sources (Sources/<Module>/), and the registerBundledPlugins entry point
858
+ - \`LeCodesRuntime/\` — local Swift package: the lecodes-ios-sdk dependency (one tag per engine
859
+ variant), vendored plugin sources (Sources/<Module>/), and LeCodesAppViewController — the SDK's
860
+ shell controller with this project's plugins registered (App/ViewController.swift subclasses it)
864
861
  - the version values inside \`App.xcodeproj/project.pbxproj\` (from app.json)
865
862
  - this README
866
863
 
867
864
  ## SDK variant
868
865
 
869
- Sync links the smallest SDK product that covers what the compiled bundle actually uses:
870
- \`core\` (UI only), \`2d\` (+ 2D engine with physics), \`3d\` (+ 3D/AR engine with physics,
871
- ~17 MB heavier), or \`full\` (everything). Adding your first 3D scene or 2D game to the
872
- project switches it automatically on the next sync; set \`ios.variant\` in \`../app.json\`
873
- to pin one instead.
866
+ Sync pins the SDK tag of the smallest engine variant that covers what the compiled bundle actually
867
+ uses: \`core\` (UI only), \`2d\` (+ the 2D engine with physics), \`3d\` (+ the 3D/AR engine with
868
+ physics, ~10 MB heavier), or \`full\` (everything) — \`<version>-core\`, \`-2d\`, \`-3d\`, and the
869
+ bare \`<version>\` for full. Adding your first 3D scene or 2D game to the project switches it
870
+ automatically on the next sync; set \`ios.variant\` in \`../app.json\` to pin one instead.
874
871
 
875
872
  ## Over-the-air updates
876
873
 
@@ -143,7 +143,7 @@ export const buildBundle = async (bundle: BundleOptions, opts: BuildBundleOption
143
143
  const project = await getProject(apiUrl, token, manifest.uuid)
144
144
  // Local-asset compiles use URL-less resources as-is instead of degrading, so skip that warning
145
145
  // there (an unpushed resources.remote match still warns below).
146
- ;({ entries, warnings } = collectEntries(root, manifest, project, { warnMissingUrl: !localAssets }))
146
+ ;({ entries, warnings } = collectEntries(root, manifest, project, { warnMissingUrl: !localAssets, localShaders: localAssets && (opts.localShaders ?? !shell) }))
147
147
  name = manifest.name
148
148
  publicUrl = bundle.publicUrl ?? apiUrl
149
149
  note(`Resolved ${entries.length} entries in ${((performance.now() - tResolve) / 1000).toFixed(1)} s`)
@@ -118,6 +118,13 @@ assertion channel for game state that has no UI.
118
118
  \`{ status?, json?, text? }\`; \`*\` wildcards full-match, plain strings substring-match, first
119
119
  match wins). \`lecodes test\` auto-loads it for design-derived flows; a scenario file opts in
120
120
  with \`"fixtures": "fixtures.json"\` in its envelope.
121
+ - A project with \`*.server.ts\` files: \`lecodes test\` runs its backend for the run, on a database
122
+ of its own that is EMPTY before every case (a flow signs up, it never finds yesterday's data). A
123
+ step waits for the server calls it started — no \`wait\` after a tap that saves.
124
+ - A file dialog (\`openFilePicker\`) is answered by \`pick\`, on the step whose action opens it:
125
+ \`{ "tap": "attach", "pick": "cat.png" }\` — a file beside the flow file, a list of them, or a
126
+ file made for the run: \`{ "name": "me.png", "width": 400, "height": 300 }\`,
127
+ \`{ "name": "notes.txt", "text": "hello" }\`. Without \`pick\` the dialog is cancelled.
121
128
  - \`fill\` fills every empty input — use it before tapping a disabled-until-filled submit.
122
129
  - Visual state is a \`$class\` in the style (\`$pressed\`, \`$hovered\`, \`$focused\`, your own
123
130
  \`$selected\` set with \`el.class.selected = true\` or bound with \`.class({ selected: () => … })\`);
@@ -5,6 +5,7 @@ import { screenTargetFrom } from "../compile/screenEntry"
5
5
  import { findProjectRoot } from "../project/manifest"
6
6
  import { buildBundle } from "./compile"
7
7
  import { runBackend } from "../dev/projectBackend"
8
+ import { resolvePicks } from "../hosts/flowPick"
8
9
  import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
9
10
  import { runDesktopRender } from "../hosts/desktopRenderer"
10
11
  import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
@@ -156,11 +157,11 @@ const readScenarioFile = (path: string): ScenarioFile => {
156
157
  } catch (e) {
157
158
  throw new CliError(`Can't read scenario "${path}": ${e instanceof Error ? e.message : String(e)}`)
158
159
  }
159
- if (Array.isArray(raw)) return { steps: raw } // a bare step array is a valid scenario file
160
+ if (Array.isArray(raw)) return { steps: resolvePicks(raw, dirname(resolve(path))) } // a bare step array is a valid scenario file
160
161
  if (typeof raw !== "object" || raw === null) throw new CliError(`Scenario "${path}" must be a JSON object with "steps" (or a bare step array).`)
161
162
  const file = raw as ScenarioFile
162
163
  if (file.steps === undefined) throw new CliError(`Scenario "${path}" has no "steps".`)
163
- return file
164
+ return { ...file, steps: resolvePicks(file.steps, dirname(resolve(path))) }
164
165
  }
165
166
 
166
167
  /** `--clip <name|x,y,w,h>`: a node name (its layout rect) or an explicit logical-px rect. */
@@ -354,7 +355,18 @@ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Pro
354
355
  }, timeoutMs)
355
356
  killer.unref?.()
356
357
 
357
- const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle({ screen, entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
358
+ // The project's backend, when it has one (the database of `lecodes dev`). Beside a running
359
+ // `lecodes dev`, which holds that database, the picture is still made — without the backend.
360
+ const run = runBackend({ root: projectRoot, command: "lecodes render", log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } })
361
+ const backend: typeof run = {
362
+ ...run,
363
+ start: (entries, onWarning) => run.start(entries, onWarning).catch((e: Error) => {
364
+ if (!/is in use/.test(e.message)) throw e
365
+ warnErr(`${e.message} Rendering without the backend: every call to the server fails.`)
366
+ return undefined
367
+ }),
368
+ }
369
+ const { js, warnings, useLocalAssets, root, serverUrl } = await compileHeadlessBundle({ screen, entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"], backend })
358
370
  const renderer = await requireRenderer()
359
371
 
360
372
  // Flags override the scenario file's envelope; both fall back to the project default (a
@@ -385,6 +397,7 @@ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Pro
385
397
  const timing = {
386
398
  settleMs: flags.settle ?? scenario?.settle,
387
399
  waitNetwork: flags["wait-network"],
400
+ backend: serverUrl,
388
401
  localAssets: useLocalAssets,
389
402
  fixtures,
390
403
  timeMs: flags.time,
@@ -395,7 +408,7 @@ const renderHeadless = async (flags: RenderFlags, file: string | undefined): Pro
395
408
 
396
409
  // Clean process exit: clear the timeout guard, flush the given output, then exit. Without this the
397
410
  // process could linger (e.g. on stdout pipe back-pressure) even though the render is done.
398
- const done = (flush: (cb: () => void) => void, code = 0) => flush(() => { clearTimeout(killer); process.exit(code) })
411
+ const done = (flush: (cb: () => void) => void, code = 0) => flush(() => { clearTimeout(killer); void backend.close().finally(() => process.exit(code)) })
399
412
 
400
413
  // --script: drive the compiled app through the scenario steps against a live session.
401
414
  if (scenario) {
@@ -5,6 +5,7 @@ import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer
5
5
  import { desktopRenderFlags, fileScenarioIO, requireRenderer, resolveFixtures, resolveSafeAreaChoice, viewportFlags } from "./render"
6
6
  import { buildBundle } from "./compile"
7
7
  import { runBackend } from "../dev/projectBackend"
8
+ import { resolvePicks } from "../hosts/flowPick"
8
9
  import { findProjectRoot } from "../project/manifest"
9
10
  import { runDesktopRender } from "../hosts/desktopRenderer"
10
11
  import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
@@ -133,13 +134,13 @@ const readFlowFile = (abs: string, displayName: string): FlowCase => {
133
134
  } catch (e) {
134
135
  throw new CliError(`Can't read "${displayName}": ${e instanceof Error ? e.message : String(e)}`)
135
136
  }
136
- if (Array.isArray(raw)) return { name: displayName, kind: "file", steps: raw }
137
+ if (Array.isArray(raw)) return { name: displayName, kind: "file", steps: resolvePicks(raw, dirname(abs)) }
137
138
  if (typeof raw !== "object" || raw === null || (raw as { steps?: unknown }).steps === undefined) {
138
139
  throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`)
139
140
  }
140
141
  const file = raw as { name?: string, width?: number, height?: number, settle?: number, device?: string, safeArea?: string, fixtures?: Record<string, unknown> | string, steps: unknown }
141
142
  return {
142
- name: file.name ?? displayName, kind: "file", steps: file.steps,
143
+ name: file.name ?? displayName, kind: "file", steps: resolvePicks(file.steps, dirname(abs)),
143
144
  fixtures: resolveFixtures(file.fixtures, dirname(abs)),
144
145
  width: file.width, height: file.height, settle: file.settle, device: file.device, safeArea: file.safeArea,
145
146
  }
@@ -170,7 +171,7 @@ export default defineCommand({
170
171
  name: "test",
171
172
  summary: "Run the flow tests (tests/*.flow.json + design-derived flows)",
172
173
  usage: "[name…]",
173
- description: "Headless flow tests: tests/*.flow.json scenarios, plus flows derived from the design graph (design/meta.json activator edges — checks the built app against the designed navigation; needs named elements + screen roots). Failing steps dump the frame (JSON + PNG) under tests/.artifacts/<flow>/. On --desktop / --lite / --web a project with *.server.ts files gets its backend run for the tests, on a database of its own (.lecodes/test-db) that is emptied before every case. The headless renderer has no backend, and --sim does not wait for one: its frames are not tied to real time.\n\nDesign flows read tests/fixtures.json automatically (canned fetch responses) so backend-gated transitions run headless; file scenarios declare their own via the \"fixtures\" envelope. [name…] runs only those files (a path, or a name under the tests folder).",
174
+ description: "Headless flow tests: tests/*.flow.json scenarios, plus flows derived from the design graph (design/meta.json activator edges — checks the built app against the designed navigation; needs named elements + screen roots). Failing steps dump the frame (JSON + PNG) under tests/.artifacts/<flow>/. A project with *.server.ts files gets its backend run for the tests, on a database of its own (.lecodes/test-db) that is emptied before every case; a step waits for the calls it started.\n\nDesign flows read tests/fixtures.json automatically (canned fetch responses) so backend-gated transitions run headless; file scenarios declare their own via the \"fixtures\" envelope. [name…] runs only those files (a path, or a name under the tests folder).",
174
175
  flags: testFlags,
175
176
  examples: ["lecodes test", "lecodes test hero --logs", "lecodes test --flows --json", "lecodes test --desktop"],
176
177
  run: ({ args, flags }) => runTests(args, flags),
@@ -193,9 +194,10 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
193
194
  try { desktopRoot = findProjectRoot(process.cwd()) } catch { desktopRoot = process.cwd() }
194
195
  const desktopRenderer = desktop ? resolveDesktopRenderer(desktopRoot, flags.renderer) : undefined
195
196
  // The project's backend, when it has one: on a database of its own, emptied before every case.
196
- const backend = desktop ? runBackend({ root: desktopRoot, command: "lecodes test", test: true, log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } }) : null
197
+ // (a browser run starts its own, in its session)
198
+ const backend = lite ? null : runBackend({ root: desktopRoot, command: "lecodes test", test: true, log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } })
197
199
  const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer, backend: backend! }) : null
198
- const headless = desktop || lite ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
200
+ const headless = desktop || lite ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"], backend: backend! })
199
201
  const warnings = headless?.warnings ?? []
200
202
  const useLocalAssets = headless?.useLocalAssets ?? false
201
203
  const js = headless?.js ?? ""
@@ -257,9 +259,10 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
257
259
  if ((meta.edges ?? []).length > 0) {
258
260
  // Probe the app for its start screen — the BFS origin of every derived flow.
259
261
  note("Probing the start screen…")
260
- const probe = await renderer!.createSession(js, { raster: false, localAssets: useLocalAssets, fixtures: flowFixtures })
262
+ const probe = await renderer!.createSession(js, { raster: false, localAssets: useLocalAssets, fixtures: flowFixtures, backend: headless!.serverUrl })
261
263
  entryScreen = probe.screenName()
262
264
  probe.dispose()
265
+ await backend?.reset()
263
266
  if (entryScreen === null) {
264
267
  warnErr("Design flows skipped: the app's start screen root has no `name`. Name each screen root with its design screen id (e.g. UIScreen(...).named(\"login\")).")
265
268
  } else {
@@ -408,6 +411,7 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
408
411
  height: flags.height || kase.height || projectViewport?.height || 844,
409
412
  settleMs: flags.settle ?? kase.settle,
410
413
  waitNetwork: flags["wait-network"],
414
+ backend: headless!.serverUrl,
411
415
  localAssets: useLocalAssets,
412
416
  fixtures: kase.fixtures,
413
417
  ...resolveSafeAreaChoice(flags["safe-area"] ?? kase.safeArea, flags.device ?? kase.device),
@@ -16,7 +16,13 @@ import type { CompileEntry } from "./projectCompile"
16
16
  */
17
17
  export const collectEntries = (
18
18
  root: string, manifest: Manifest, project: Project,
19
- opts: { /** Warn about resources with no server URL yet (default true; shell compiles bundle them instead). */ warnMissingUrl?: boolean } = {},
19
+ opts: {
20
+ /** Warn about resources with no server URL yet (default true; shell compiles bundle them instead). */
21
+ warnMissingUrl?: boolean
22
+ /** The caller compiles the .mat files itself (applyLocalShaders in `local` mode — the desktop
23
+ * host paths): skip the "push it to compile the shader" notice, which would be wrong there. */
24
+ localShaders?: boolean
25
+ } = {},
20
26
  ): { entries: CompileEntry[], warnings: string[] } => {
21
27
  const pathById = buildPaths(project.assets)
22
28
  const serverByPath = new Map<string, { fileSrc?: string, shaders?: { platform: string, src: string }[] }>()
@@ -36,8 +42,11 @@ export const collectEntries = (
36
42
  entries.push({ path: file.path, type, fileSrc: src, absPath: file.absPath })
37
43
  } else if (type === "shader") {
38
44
  const shaders = serverByPath.get(file.path)?.shaders
39
- if (!shaders?.length) warnings.push(`No compiled shader for ${file.path} — push it to compile the shader.`)
40
- entries.push({ path: file.path, type, shaders })
45
+ if (!shaders?.length && !opts.localShaders) warnings.push(`No compiled shader for ${file.path} — push it to compile the shader.`)
46
+ // absPath: the local shader compile (compile/shaders.ts) reads the .mat source through it and
47
+ // selects the entries that carry it — without it a server-backed project's shaders were never
48
+ // compiled locally, and `desktop run` shipped only what the server had (nothing until a push).
49
+ entries.push({ path: file.path, type, shaders, absPath: file.absPath })
41
50
  } else {
42
51
  entries.push({ path: file.path, type, text: readFileSync(file.absPath, "utf8") })
43
52
  }
@@ -8,6 +8,7 @@ import { applyProjectFonts } from "./fonts"
8
8
  import { applyAppAssetIcons } from "./assetIcons"
9
9
  import type { ScreenTarget } from "./screenEntry"
10
10
  import { note } from "../cli"
11
+ import type { RunBackend } from "../dev/projectBackend"
11
12
 
12
13
  /*
13
14
  * Compile the project into the iife bundle the headless renderer runs — shared by `lecodes render`
@@ -34,11 +35,15 @@ export type HeadlessBundle = {
34
35
  warnings: string[]
35
36
  /** Whether asset URLs resolve from the local disk (`lecodesfile://`). */
36
37
  useLocalAssets: boolean
38
+ /** The url of the backend started for this run (`backend`), when the project has server files —
39
+ * what the renderer is told to let through. */
40
+ serverUrl?: string
37
41
  }
38
42
 
39
43
  /** What a headless compile honours: `--entry` / `--public-url` / `--remote-assets` / `--wait-network`,
40
- * and the screen module a `render <file>` named. */
41
- export type HeadlessOptions = { entry?: string, publicUrl?: string, remoteAssets?: boolean, waitNetwork?: boolean, screen?: ScreenTarget }
44
+ * and the screen module a `render <file>` named. `backend`: the run's backend — started here when the
45
+ * project has server files, its url put in the app's stubs (without one they have no server to call). */
46
+ export type HeadlessOptions = { entry?: string, publicUrl?: string, remoteAssets?: boolean, waitNetwork?: boolean, screen?: ScreenTarget, backend?: RunBackend }
42
47
 
43
48
  /** Compile the local project for a headless run. */
44
49
  export const compileHeadlessBundle = async (opts: HeadlessOptions = {}): Promise<HeadlessBundle> => {
@@ -112,10 +117,12 @@ export const compileHeadlessBundle = async (opts: HeadlessOptions = {}): Promise
112
117
  const ambiguous = ambiguousEntryWarning(compileEntries)
113
118
  if (ambiguous) warnings.push(ambiguous)
114
119
  }
120
+ const serverUrl = await opts.backend?.start(compileEntries, (w) => warnings.push(w))
115
121
  const js = await compileProject({
116
122
  entries: compileEntries,
117
123
  name,
118
124
  publicUrl,
125
+ serverUrl,
119
126
  entryOverride: entryFlag,
120
127
  header: false,
121
128
  format: "iife",
@@ -129,5 +136,5 @@ export const compileHeadlessBundle = async (opts: HeadlessOptions = {}): Promise
129
136
  warnings.push("2D scene detected: sprite textures load over the network — add --wait-network to include them (else sprites render as flat placeholders).")
130
137
  }
131
138
 
132
- return { js, root, warnings, useLocalAssets }
139
+ return { js, root, warnings, useLocalAssets, serverUrl }
133
140
  }
@@ -10,6 +10,9 @@ declare module "lecodes-headless/headless" {
10
10
  settleMs?: number
11
11
  /** Let data fetches hit the real network (off by default). */
12
12
  waitNetwork?: boolean
13
+ /** The url of the project's backend, run for this run: the app's calls to it are made and
14
+ * waited for, its channel socket is real. */
15
+ backend?: string
13
16
  /** Resolve `lecodesfile://<abs-path>` resource URLs from the local filesystem (the `render`
14
17
  * command emits these for a local project's assets). */
15
18
  localAssets?: boolean
@@ -1,10 +1,10 @@
1
- import { mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs"
1
+ import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs"
2
2
  import { tmpdir } from "node:os"
3
3
  import { join } from "node:path"
4
4
  import { format } from "node:util"
5
5
  import {
6
- createAuthHost, createInvoker, createSubscribeGuard, describeServer, loadServerModules,
7
- setChannelPublisher, setDbTransport, type AuthHost, type LoadedServer,
6
+ createAuthHost, createFilesHost, createInvoker, createSubscribeGuard, describeServer, loadServerModules,
7
+ setChannelPublisher, setDbTransport, type AuthHost, type Heic, type LoadedServer, type Sharp,
8
8
  } from "lecodes-sdk/server"
9
9
  import { loadPeer } from "../hosts/peerInstall"
10
10
  import type { FromWorker, LogLevel, ToWorker } from "./localBackend"
@@ -59,7 +59,7 @@ process.on("SIGINT", quit)
59
59
  process.on("SIGTERM", quit)
60
60
 
61
61
  /** Open the project's database and bring it to `schema`. Returns what the developer should be told. */
62
- const openDb = async (dir: string, schema: string): Promise<string[]> => {
62
+ const openDb = async (dir: string, filesDir: string, schema: string): Promise<string[]> => {
63
63
  const { openDatabase } = await loadPeer<EmbeddedModule>("marcidb-embedded", undefined, { for: "lecodes dev", install: false })
64
64
  ?? (() => { throw new Error("marcidb-embedded is not installed") })()
65
65
  mkdirSync(dir, { recursive: true })
@@ -85,6 +85,9 @@ const openDb = async (dir: string, schema: string): Promise<string[]> => {
85
85
  const aside = `${dir}-old`
86
86
  rmSync(aside, { recursive: true, force: true })
87
87
  renameSync(dir, aside)
88
+ // the stored files are that database's: they go aside with it
89
+ rmSync(`${filesDir}-old`, { recursive: true, force: true })
90
+ if (existsSync(filesDir)) renameSync(filesDir, `${filesDir}-old`)
88
91
  mkdirSync(dir, { recursive: true })
89
92
  db = openDatabase(dir)
90
93
  await db.$sync(schema)
@@ -100,7 +103,7 @@ const load = async (m: Extract<ToWorker, { t: "load" }>) => {
100
103
  ;(0, eval)(m.code)
101
104
  const server: LoadedServer = loadServerModules()
102
105
  const describe = describeServer(server)
103
- const notes = describe.marci ? await openDb(m.dbDir, describe.marci) : []
106
+ const notes = describe.marci ? await openDb(m.dbDir, m.filesDir, describe.marci) : []
104
107
  // Sessions, passwords and email codes over the project's own db, as on the runner. A code that was
105
108
  // asked for is printed here, and DEV_CODE signs any email in without one — the developer is the
106
109
  // only user.
@@ -114,7 +117,15 @@ const load = async (m: Extract<ToWorker, { t: "load" }>) => {
114
117
  authHost.install()
115
118
  const auth = authHost.enabled ? authHost : null
116
119
  const onError = (id: string, e: unknown) => post({ t: "log", level: "error", text: `${id}: ${(e as Error)?.stack ?? e}` })
117
- invoke = createInvoker(server, { auth, onError })
120
+ // Stored files (`t.file()`): rows in the database, bytes in the folder beside it
121
+ const files = createFilesHost({
122
+ db: server.dbs[0] ?? null, dir: m.filesDir, log: (level, text) => post({ t: "log", level, text }),
123
+ // the codec of t.image() fields: a peer, installed by the parent when the bundle has one (projectBackend.ts)
124
+ sharp: async () => (await loadPeer<{ default: Sharp }>("sharp", undefined, { for: "lecodes dev", install: false }))?.default ?? null,
125
+ heic: async () => (await loadPeer<{ default: Heic }>("heic-decode", undefined, { for: "lecodes dev", install: false }))?.default ?? null,
126
+ })
127
+ files.install()
128
+ invoke = createInvoker(server, { auth, files, onError })
118
129
  subscribe = createSubscribeGuard(server, { auth, onError })
119
130
  post({ t: "loaded", describe, notes })
120
131
  }
@@ -25,7 +25,7 @@ export type LogLevel = "log" | "info" | "warn" | "error" | "debug"
25
25
  type Ctx = Omit<RequestContext, "id">
26
26
 
27
27
  export type ToWorker =
28
- | { t: "load", code: string, dbDir: string }
28
+ | { t: "load", code: string, dbDir: string, filesDir: string }
29
29
  | { t: "invoke", req: number, id: string, args: unknown[], ctx: Ctx }
30
30
  | { t: "subscribe", req: number, ch: string, args: unknown[], ctx: Ctx }
31
31
  | { t: "stop" }
@@ -41,6 +41,8 @@ export type LocalBackendOptions = {
41
41
  root: string
42
42
  /** The database's folder (default `.lecodes/db`). */
43
43
  dbDir?: string
44
+ /** The stored files' folder (`t.file()`; default: beside the database — `.lecodes/files`). */
45
+ filesDir?: string
44
46
  log: (msg: string) => void
45
47
  onPublish: (ch: string, group: ChannelGroup | null, data: unknown) => void
46
48
  invokeTimeoutMs?: number
@@ -74,11 +76,15 @@ const selfCommand = (): string[] => {
74
76
  return [process.execPath, ...process.execArgv, entry]
75
77
  }
76
78
 
79
+ /** Where a database's stored files are: beside it, named after it (`.lecodes/db` → `.lecodes/files`, `test-db` → `test-files`). */
80
+ export const filesDirOf = (dbDir: string) => join(dirname(dbDir), basename(dbDir).replace(/db$/, "") + "files")
81
+
77
82
  export const startLocalBackend = (options: LocalBackendOptions): LocalBackend => {
78
83
  const { log } = options
79
84
  const invokeTimeoutMs = options.invokeTimeoutMs ?? 30_000
80
85
  const loadTimeoutMs = options.loadTimeoutMs ?? 15_000
81
86
  const dbDir = options.dbDir ?? join(options.root, LECODES_DIR, "db")
87
+ const filesDir = options.filesDir ?? filesDirOf(dbDir)
82
88
 
83
89
  let live: Live | null = null
84
90
  /** The bundle that is deployed — what a crashed or timed-out process is started from again. */
@@ -141,7 +147,7 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
141
147
  if (!closed) log(`[server:error] the server process exited (${status ?? "killed"}) — it starts again with the next call`)
142
148
  }
143
149
  })
144
- child.send({ t: "load", code: bundle, dbDir } satisfies ToWorker)
150
+ child.send({ t: "load", code: bundle, dbDir, filesDir } satisfies ToWorker)
145
151
  })
146
152
 
147
153
  const stop = async (l: Live) => {