lecodes-cli 0.16.2 → 0.17.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.
package/dist/index.js CHANGED
@@ -215816,7 +215816,7 @@ var require_main = __commonJS((exports, module) => {
215816
215816
  // package.json
215817
215817
  var package_default = {
215818
215818
  name: "lecodes-cli",
215819
- version: "0.16.2",
215819
+ version: "0.17.0",
215820
215820
  dependencies: {
215821
215821
  "@letary/chisel": "^0.8.0",
215822
215822
  jimp: "^1.6.1"
@@ -215827,11 +215827,12 @@ var package_default = {
215827
215827
  "qrcode-terminal": "^0.12.0",
215828
215828
  "lecodes-3d-editor": "workspace:*",
215829
215829
  "lecodes-assets": "workspace:*",
215830
- sdk: "workspace:*"
215830
+ sdk: "workspace:*",
215831
+ "viewer-lite": "workspace:*"
215831
215832
  },
215832
215833
  peerDependencies: {
215833
215834
  "lecodes-design": "^0.8.1",
215834
- "lecodes-renderer": "^0.8.4",
215835
+ "lecodes-renderer": "^0.8.5",
215835
215836
  "lecodes-3d-editor": "^0.3.0",
215836
215837
  "lecodes-assets": "^0.1.0"
215837
215838
  },
@@ -215891,8 +215892,8 @@ import { basename as basename6, dirname as dirname12, join as join17, resolve as
215891
215892
  init_api();
215892
215893
  init_config();
215893
215894
  init_manifest();
215894
- import { writeFileSync as writeFileSync7 } from "node:fs";
215895
- import { basename as basename3, resolve } from "node:path";
215895
+ import { writeFileSync as writeFileSync8 } from "node:fs";
215896
+ import { basename as basename3, resolve as resolve2 } from "node:path";
215896
215897
 
215897
215898
  // src/compile/collect.ts
215898
215899
  init_localFiles();
@@ -216005,6 +216006,226 @@ init_projectCompile();
216005
216006
  init_fonts();
216006
216007
  init_assetIcons();
216007
216008
  init_ignore();
216009
+
216010
+ // src/commands/appShared.ts
216011
+ init_manifest();
216012
+ init_ignore();
216013
+ init_util();
216014
+ import { execFileSync } from "node:child_process";
216015
+ import { existsSync as existsSync10, mkdirSync as mkdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "node:fs";
216016
+ import { homedir as homedir3 } from "node:os";
216017
+ import { dirname as dirname8, join as join12, resolve } from "node:path";
216018
+ var FIRST_PARTY_PLUGINS = {
216019
+ camera: "letary/lecodes-plugins",
216020
+ "qr-scanner": "letary/lecodes-plugins",
216021
+ geolocation: "letary/lecodes-plugins",
216022
+ push: "letary/lecodes-plugins"
216023
+ };
216024
+ var readAppConfigAt = (root) => readJson(join12(root, "app.json"));
216025
+ var DESKTOP_DEFAULT_WINDOW = { width: 1280, height: 720 };
216026
+ var desktopDefaultViewport = (root) => {
216027
+ const cfg = readAppConfigAt(root);
216028
+ if (!cfg?.desktop || cfg.ios || cfg.android)
216029
+ return null;
216030
+ return {
216031
+ width: cfg.desktop.window?.width ?? DESKTOP_DEFAULT_WINDOW.width,
216032
+ height: cfg.desktop.window?.height ?? DESKTOP_DEFAULT_WINDOW.height
216033
+ };
216034
+ };
216035
+ var readJson = (path) => {
216036
+ if (!existsSync10(path))
216037
+ return null;
216038
+ try {
216039
+ return JSON.parse(readFileSync11(path, "utf8").replace(/^\uFEFF/, ""));
216040
+ } catch {
216041
+ throw new CliError(`Invalid JSON in ${path}`);
216042
+ }
216043
+ };
216044
+ var writeJson = (path, value) => writeFileSync7(path, JSON.stringify(value, null, 2) + `
216045
+ `);
216046
+ var projectRoot = () => {
216047
+ try {
216048
+ return findProjectRoot(process.cwd());
216049
+ } catch {
216050
+ return process.cwd();
216051
+ }
216052
+ };
216053
+ var git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
216054
+ var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "app";
216055
+ var runTool = (cwd, cmd, args) => {
216056
+ try {
216057
+ return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 128 * 1024 * 1024 });
216058
+ } catch (e) {
216059
+ const err = e;
216060
+ const tail = ((err.stdout ?? "") + `
216061
+ ` + (err.stderr ?? "")).split(`
216062
+ `).filter((l) => l.trim()).slice(-25).join(`
216063
+ `);
216064
+ throw new CliError(`${cmd} ${args[0] ?? ""} failed:
216065
+ ${tail}`);
216066
+ }
216067
+ };
216068
+ var ensureShellIgnored = (root, dir) => {
216069
+ const ignorePath = join12(root, ".lecodesignore");
216070
+ const ignore = existsSync10(ignorePath) ? readFileSync11(ignorePath, "utf8") : DEFAULT_IGNORE_FILE;
216071
+ if (new RegExp(`^${dir}\\/$`, "m").test(ignore))
216072
+ return;
216073
+ const comment = ignore.includes("# Native app shells") ? "" : "# Native app shells — generated by `lecodes app init`, never pushed to LeCodes.\n";
216074
+ writeFileSync7(ignorePath, ignore.replace(/\n*$/, `
216075
+ `) + `
216076
+ ` + comment + `${dir}/
216077
+ `);
216078
+ log(`${c.green("updated")} .lecodesignore (+ ${dir}/)`);
216079
+ };
216080
+ var SDK_VARIANTS = ["core", "2d", "3d", "full"];
216081
+ var detectEngines = (bundleJs) => {
216082
+ const lines = [];
216083
+ for (const line of bundleJs.split(`
216084
+ `)) {
216085
+ if (!line.startsWith("//"))
216086
+ break;
216087
+ lines.push(line);
216088
+ }
216089
+ const header = lines.join(`
216090
+ `);
216091
+ return { gl: !/^\/\/ gl: disable$/m.test(header), g2: /^\/\/ 2d: enable$/m.test(header) };
216092
+ };
216093
+ var resolveVariant = (requested, bundleJs, configKey = "ios.variant") => {
216094
+ const uses = detectEngines(bundleJs);
216095
+ const detected = uses.gl && uses.g2 ? "full" : uses.gl ? "3d" : uses.g2 ? "2d" : "core";
216096
+ if (requested === "auto")
216097
+ return { variant: detected, detected };
216098
+ const variant = requested;
216099
+ const missing = [
216100
+ uses.gl && variant !== "3d" && variant !== "full" ? "3D" : null,
216101
+ uses.g2 && variant !== "2d" && variant !== "full" ? "2D" : null
216102
+ ].filter(Boolean);
216103
+ if (missing.length > 0) {
216104
+ warn(`project uses the ${missing.join(" + ")} engine but ${configKey} forces "${variant}" — those scenes won't run on device; remove ${configKey} to auto-select "${detected}".`);
216105
+ }
216106
+ return { variant, detected };
216107
+ };
216108
+ var VERSION_RE = /^\d+\.\d+\.\d+$/;
216109
+ var cmpVersion = (a, b) => {
216110
+ const pa = a.split(".").map(Number);
216111
+ const pb = b.split(".").map(Number);
216112
+ for (let i = 0;i < 3; i++) {
216113
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
216114
+ return (pa[i] ?? 0) - (pb[i] ?? 0);
216115
+ }
216116
+ return 0;
216117
+ };
216118
+ var fetchLatestSdkVersion = async (repo) => {
216119
+ const url = `https://api.github.com/repos/${repo}/releases/latest`;
216120
+ const res = await fetch(url, { headers: { accept: "application/vnd.github+json", "user-agent": "lecodes-cli" } });
216121
+ if (!res.ok)
216122
+ throw new CliError(`Could not read the latest ${repo} release (HTTP ${res.status}) — ${url}`);
216123
+ const tag = (await res.json()).tag_name?.replace(/^v/, "") ?? "";
216124
+ if (!VERSION_RE.test(tag))
216125
+ throw new CliError(`${repo} latest release has no x.y.z tag (got "${tag}")`);
216126
+ return tag;
216127
+ };
216128
+ var resolveSdkVersion = async (pin, fallback, opts) => {
216129
+ if (pin !== "latest") {
216130
+ if (!VERSION_RE.test(pin)) {
216131
+ throw new CliError(`app.json ${opts.configKey} must be "latest" or an exact x.y.z release (got "${pin}").`);
216132
+ }
216133
+ if (cmpVersion(pin, opts.minVersion) < 0) {
216134
+ throw new CliError(`app.json ${opts.configKey} ${pin} is older than the ${opts.minVersion} these generated shells need.`);
216135
+ }
216136
+ return pin;
216137
+ }
216138
+ try {
216139
+ return await fetchLatestSdkVersion(opts.repo);
216140
+ } catch (e) {
216141
+ const kept = fallback ?? opts.minVersion;
216142
+ warn(`Could not resolve the latest SDK (${e.message}) — staying on ${kept}.`);
216143
+ return kept;
216144
+ }
216145
+ };
216146
+ var ensureRepo = (repo, wantTag, lock) => {
216147
+ const override = process.env.LECODES_PLUGINS_DIR;
216148
+ if (override) {
216149
+ note(`Using local plugin checkout ${override} (LECODES_PLUGINS_DIR)`);
216150
+ return { dir: resolve(override), tag: "local", commit: "local" };
216151
+ }
216152
+ const cache = join12(homedir3(), ".lecodes", "plugins", repo.replace("/", "__"));
216153
+ const url = `https://github.com/${repo}.git`;
216154
+ if (!existsSync10(join12(cache, ".git"))) {
216155
+ mkdirSync6(dirname8(cache), { recursive: true });
216156
+ note(`Cloning ${url}…`);
216157
+ git(dirname8(cache), "clone", "--quiet", url, cache);
216158
+ }
216159
+ let tag = lock?.tag ?? wantTag;
216160
+ let commit = lock?.commit;
216161
+ if (commit) {
216162
+ try {
216163
+ git(cache, "cat-file", "-e", `${commit}^{commit}`);
216164
+ } catch {
216165
+ git(cache, "fetch", "--quiet", "--tags", "origin");
216166
+ }
216167
+ } else {
216168
+ git(cache, "fetch", "--quiet", "--tags", "origin");
216169
+ if (!tag) {
216170
+ tag = git(cache, "tag", "--list", "v*", "--sort=-version:refname").split(`
216171
+ `)[0] ?? "";
216172
+ if (!tag)
216173
+ throw new CliError(`${repo} has no v* version tags to install from`);
216174
+ }
216175
+ commit = git(cache, "rev-list", "-n", "1", tag);
216176
+ }
216177
+ git(cache, "checkout", "--quiet", "--detach", commit);
216178
+ return { dir: cache, tag: tag ?? "unknown", commit };
216179
+ };
216180
+ var resolvePlugin = (id, spec, lock, platform) => {
216181
+ let repo;
216182
+ let subdir;
216183
+ let wantTag = null;
216184
+ if (spec.startsWith("github:")) {
216185
+ const [repoPart, tagPart] = spec.slice("github:".length).split("@");
216186
+ repo = repoPart;
216187
+ wantTag = tagPart ?? null;
216188
+ subdir = "";
216189
+ } else {
216190
+ repo = FIRST_PARTY_PLUGINS[id];
216191
+ if (!repo) {
216192
+ throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use a github:owner/repo spec.`);
216193
+ }
216194
+ subdir = `plugins/${id}`;
216195
+ }
216196
+ const checkout = ensureRepo(repo, wantTag, lock?.repo === repo ? lock : undefined);
216197
+ const dir = subdir ? join12(checkout.dir, subdir) : checkout.dir;
216198
+ const manifest = readJson(join12(dir, "lecodes-plugin.json"));
216199
+ if (!manifest)
216200
+ throw new CliError(`Plugin "${id}": no lecodes-plugin.json in ${dir}`);
216201
+ if (manifest.id !== id)
216202
+ throw new CliError(`Plugin "${id}": manifest declares id "${manifest.id}"`);
216203
+ if (!manifest[platform]) {
216204
+ throw new CliError(`Plugin "${id}" does not support ${platform === "ios" ? "iOS" : "Android"} (manifest "${platform}" is null)`);
216205
+ }
216206
+ if (spec.startsWith("github:")) {
216207
+ const names = [...manifest.provides?.views ?? [], ...manifest.provides?.services ?? []];
216208
+ for (const name of names) {
216209
+ if (!name.includes("."))
216210
+ warn(`Plugin "${id}" provides bare channel name "${name}" — third-party names should be vendor-prefixed (e.g. "acme.${name}").`);
216211
+ }
216212
+ }
216213
+ return {
216214
+ id,
216215
+ dir,
216216
+ manifest,
216217
+ lock: { repo, tag: checkout.tag, commit: checkout.commit, version: manifest.version ?? "unknown" }
216218
+ };
216219
+ };
216220
+ var latestPluginTag = (repo) => {
216221
+ try {
216222
+ return ensureRepo(repo, null, undefined).tag;
216223
+ } catch {
216224
+ return null;
216225
+ }
216226
+ };
216227
+
216228
+ // src/commands/compile.ts
216008
216229
  init_util();
216009
216230
  var compile2 = async (args) => {
216010
216231
  await compileImpl(args);
@@ -216016,7 +216237,7 @@ var compileImpl = async (args, shell) => {
216016
216237
  if (out && isNullSink(out)) {
216017
216238
  note(`Discarded output (${out})`);
216018
216239
  } else if (out) {
216019
- writeFileSync7(resolve(out), js);
216240
+ writeFileSync8(resolve2(out), js);
216020
216241
  note(`Wrote ${out} (${js.length} bytes)`);
216021
216242
  } else {
216022
216243
  process.stdout.write(js);
@@ -216027,7 +216248,7 @@ var compileImpl = async (args, shell) => {
216027
216248
  }
216028
216249
  return resources;
216029
216250
  };
216030
- var buildBundle = async (args, shell) => {
216251
+ var buildBundle = async (args, shell, opts = {}) => {
216031
216252
  let root;
216032
216253
  let manifest;
216033
216254
  try {
@@ -216046,19 +216267,21 @@ var buildBundle = async (args, shell) => {
216046
216267
  const config = loadConfig();
216047
216268
  const apiUrl = requireApiUrl(config);
216048
216269
  const token = requireToken(config);
216270
+ const publicUrlFlag = flagStr(args, "public-url");
216271
+ localAssets = !!shell || !!opts.localAssets && publicUrlFlag === undefined && !flagBool(args, "remote-assets");
216049
216272
  note("Resolving project assets…");
216050
216273
  const project = await getProject(apiUrl, token, manifest.uuid);
216051
- ({ entries, warnings } = collectEntries(root, manifest, project, { warnMissingUrl: !shell }));
216274
+ ({ entries, warnings } = collectEntries(root, manifest, project, { warnMissingUrl: !localAssets }));
216052
216275
  name = manifest.name;
216053
- publicUrl = flagStr(args, "public-url") ?? apiUrl;
216054
- localAssets = !!shell;
216055
- if (shell && shell.remotePatterns.length > 0) {
216056
- const isRemote = buildGlobMatcher(shell.remotePatterns);
216276
+ publicUrl = publicUrlFlag ?? apiUrl;
216277
+ const remotePatterns = shell?.remotePatterns ?? (localAssets ? readAppConfigAt(root)?.resources?.remote ?? [] : []);
216278
+ if (localAssets && remotePatterns.length > 0) {
216279
+ const isRemote = buildGlobMatcher(remotePatterns);
216057
216280
  for (const e of entries) {
216058
216281
  if (e.type !== "resource" || !isRemote(e.path))
216059
216282
  continue;
216060
216283
  if (!e.fileSrc) {
216061
- warnErr(`${e.path} matches resources.remote but has no server URL yet — bundling it (push, then re-sync).`);
216284
+ warnErr(`${e.path} matches resources.remote but has no server URL yet — ${shell ? "bundling it (push, then re-sync)" : "reading it from the working tree"}.`);
216062
216285
  continue;
216063
216286
  }
216064
216287
  e.remote = true;
@@ -216068,13 +216291,16 @@ var buildBundle = async (args, shell) => {
216068
216291
  const publicUrlFlag = flagStr(args, "public-url");
216069
216292
  localAssets = shell ? true : publicUrlFlag === undefined;
216070
216293
  publicUrl = publicUrlFlag ?? "";
216294
+ if (opts.localAssets && flagBool(args, "remote-assets")) {
216295
+ warnErr("This is a local project (no server) — assets always load from the working tree.");
216296
+ }
216071
216297
  ({ entries, warnings } = collectLocalEntries(root, null, ""));
216072
216298
  name = basename3(root);
216073
216299
  if (shell && shell.remotePatterns.length > 0) {
216074
216300
  warnErr("resources.remote needs a server-backed project (a streamed asset needs a server URL) — bundling everything.");
216075
216301
  }
216076
216302
  }
216077
- const fontsHeader = await applyProjectFonts(entries, manifest && !shell ? { mode: "cdn", root, publicUrl } : {
216303
+ const fontsHeader = await applyProjectFonts(entries, manifest && !localAssets ? { mode: "cdn", root, publicUrl } : {
216078
216304
  mode: "vendor",
216079
216305
  root,
216080
216306
  vendorDir: root,
@@ -216822,358 +217048,140 @@ let package = Package(
216822
217048
  name: "LeCodesRuntime",
216823
217049
  dependencies: [
216824
217050
  ${runtimeDeps.join(`,
216825
- `)},
216826
- ],
216827
- path: "Sources/LeCodesRuntime"
216828
- ),${pluginTargets}
216829
- ]
216830
- )
216831
- `;
216832
- };
216833
- var runtimeSwiftTemplate = (plugins, projectUuid) => {
216834
- const imports = plugins.map((p) => `import ${p.module}
216835
- `).join("");
216836
- const identity = !projectUuid ? "" : ` // World identity: this shell IS the project — the engine attributes the boot world so
216837
- // identity-gated services (push registration) see the app's uuid from its first
216838
- // synchronous statement. Baked from .lecodes/manifest.json by \`lecodes app sync\`.
216839
- engine.bootProjectUuid = "${projectUuid}"
216840
- `;
216841
- const calls = plugins.map((p) => ` ${p.register}.register(in: engine)
216842
- `).join("");
216843
- const body = identity + calls;
216844
- return `//
216845
- // LeCodesRuntime.swift
216846
- // GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
216847
- //
216848
- // Re-exports the SDK (so app code needs only \`import LeCodesRuntime\`) and registers
216849
- // every plugin installed in app.json.
216850
- //
216851
-
216852
- @_exported import LeCodesSDK
216853
- ${imports}
216854
- public func registerBundledPlugins(in engine: LeCodesEngine) {
216855
- ${body === "" ? `
216856
- ` : body}}
216857
- `;
216858
- };
216859
- var IOS_README = `# ios/ — native Xcode shell
216860
-
216861
- Generated by \`lecodes app init ios\` for the LeCodes project one folder up. The app runs the
216862
- compiled bundle in \`App/Resources/app.js\` — fully offline, nothing fetched at launch. This
216863
- folder is ignored by the platform (\`ios/\` in \`.lecodesignore\`) and is never pushed to LeCodes.
216864
-
216865
- ## Daily workflow
216866
-
216867
- Edit the app's code at the project root (\`main.ts\` …), then:
216868
-
216869
- \`\`\`
216870
- lecodes app sync # recompile app.js + assets into the shell, vendor plugins
216871
- lecodes app open # open App.xcodeproj in Xcode — build & run from there
216872
- \`\`\`
216873
-
216874
- \`lecodes app doctor\` preflights builds; \`lecodes app build ios\` archives and exports an
216875
- .ipa; \`lecodes app upload ios\` sends the archive to App Store Connect.
216876
-
216877
- ## SDK version
216878
-
216879
- \`../app.json\` \`ios.sdk\` is the exact \`lecodes-ios-sdk\` release this shell links —
216880
- \`lecodes app init\` pinned the newest one at the time, so builds stay reproducible.
216881
-
216882
- \`\`\`
216883
- lecodes app update # → newest SDK release + newest plugin tags, then sync
216884
- lecodes app update --check # what's available, writes nothing
216885
- lecodes app update --sdk 1.3.0
216886
- \`\`\`
216887
-
216888
- Set \`"sdk": "latest"\` instead of a version to re-resolve on every sync (needs network;
216889
- offline it keeps whatever the shell already links). The pin is exact rather than a range
216890
- because the CLI also downloads that release's filament resources bundle into
216891
- \`App/Resources/\` — the two halves of one SDK must not drift apart.
216892
-
216893
- ## Who owns what
216894
-
216895
- Yours — \`lecodes app sync\` never touches these:
216896
-
216897
- - \`App/*.swift\` (AppDelegate, SceneDelegate, ViewController) — customize freely, add files.
216898
- App-local native code goes here too: add a Swift file that calls
216899
- \`engine.registerCallback\` / \`registerService\`, and invoke it from ViewController.
216900
- - \`App/Assets.xcassets/\` — the app icon. Easy path: set \`app.json\` \`"icon"\` to a square
216901
- ≥1024px image and every sync renders AppIcon.png from it (flattened over \`"iconBackground"\`,
216902
- default white — App Store icons must be opaque). Or leave the key unset and drop a
216903
- 1024×1024 PNG into AppIcon.appiconset/ yourself — sync never touches it then.
216904
- - \`App/Base.lproj/LaunchScreen.storyboard\`
216905
-
216906
- CLI-owned — regenerated on every sync, hand edits are overwritten:
216907
-
216908
- - \`App/Resources/\` — the compiled app.js + every asset it references (fonts included), so
216909
- the app runs fully offline. Globs in app.json \`"resources": {"remote": [...]}\` opt heavy
216910
- assets (videos, big models) back into streaming from the server instead.
216911
- - \`App/Info.plist\` — template + the installed plugins' usage keys
216912
- - \`App/App.entitlements\` — the capabilities the installed plugins declare (e.g. push)
216913
- - \`LeCodesRuntime/\` — local Swift package: the LeCodesSDK dependency, vendored plugin
216914
- sources (Sources/<Module>/), and the registerBundledPlugins entry point
216915
- - the version values inside \`App.xcodeproj/project.pbxproj\` (from app.json)
216916
- - this README
216917
-
216918
- ## SDK variant
216919
-
216920
- Sync links the smallest SDK product that covers what the compiled bundle actually uses:
216921
- \`core\` (UI only), \`2d\` (+ 2D engine with physics), \`3d\` (+ 3D/AR engine with physics,
216922
- ~17 MB heavier), or \`full\` (everything). Adding your first 3D scene or 2D game to the
216923
- project switches it automatically on the next sync; set \`ios.variant\` in \`../app.json\`
216924
- to pin one instead.
216925
-
216926
- ## Over-the-air updates
216927
-
216928
- With \`"update": true\` in \`../app.json\` (server-backed projects; or
216929
- \`"update": {"url": "https://…/app.js"}\` for any static hosting), the app checks that URL
216930
- at launch — natively, before any JS runs, so a broken bundle can never block its own fix.
216931
- Unchanged code costs a ~0-byte 304; new code downloads in the background and runs on the
216932
- NEXT launch. Offline it's a silent no-op — the app always launches from a local bundle.
216933
- An App Store update always beats a cached OTA bundle. JS only: adding/removing native
216934
- plugins still needs a store release.
216935
-
216936
- Shipping an update is just \`lecodes push --publish\` — no rebuild, no store review:
216937
- running apps download the new code on their next launch and run it on the launch after.
216938
-
216939
- Shells generated before OTA support have a \`ViewController.swift\` that reads app.js from
216940
- \`Bundle.main\` directly, so downloaded updates would never run — and since \`App/*.swift\`
216941
- is yours, sync won't rewrite it (\`lecodes app doctor\` flags this). Replace the
216942
- \`Bundle.main\` load with:
216943
-
216944
- \`\`\`swift
216945
- LeCodesUpdater.checkForUpdate()
216946
- guard let code = LeCodesUpdater.activeBundle() else {
216947
- print("LeCodes: bundled app.js is missing — run \`lecodes app sync\`")
216948
- return
216949
- }
216950
- engine.run(code)
216951
- \`\`\`
216952
-
216953
- ## Config
216954
-
216955
- Identity lives in \`../app.json\` (name, version, plugins, ios.bundleId / teamId / variant /
216956
- buildNumber) — edit it there, then re-run \`lecodes app sync\`. Plugins are managed with
216957
- \`lecodes app plugin add <id | github:owner/repo[@tag]>\` and pinned in \`../plugins.lock.json\`.
216958
- `;
216959
-
216960
- // src/commands/appShared.ts
216961
- init_manifest();
216962
- init_ignore();
216963
- init_util();
216964
- import { execFileSync } from "node:child_process";
216965
- import { existsSync as existsSync10, mkdirSync as mkdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "node:fs";
216966
- import { homedir as homedir3 } from "node:os";
216967
- import { dirname as dirname8, join as join12, resolve as resolve2 } from "node:path";
216968
- var FIRST_PARTY_PLUGINS = {
216969
- camera: "letary/lecodes-plugins",
216970
- "qr-scanner": "letary/lecodes-plugins",
216971
- geolocation: "letary/lecodes-plugins",
216972
- push: "letary/lecodes-plugins"
216973
- };
216974
- var readAppConfigAt = (root) => readJson(join12(root, "app.json"));
216975
- var DESKTOP_DEFAULT_WINDOW = { width: 1280, height: 720 };
216976
- var desktopDefaultViewport = (root) => {
216977
- const cfg = readAppConfigAt(root);
216978
- if (!cfg?.desktop || cfg.ios || cfg.android)
216979
- return null;
216980
- return {
216981
- width: cfg.desktop.window?.width ?? DESKTOP_DEFAULT_WINDOW.width,
216982
- height: cfg.desktop.window?.height ?? DESKTOP_DEFAULT_WINDOW.height
216983
- };
216984
- };
216985
- var readJson = (path) => {
216986
- if (!existsSync10(path))
216987
- return null;
216988
- try {
216989
- return JSON.parse(readFileSync11(path, "utf8").replace(/^\uFEFF/, ""));
216990
- } catch {
216991
- throw new CliError(`Invalid JSON in ${path}`);
216992
- }
216993
- };
216994
- var writeJson = (path, value) => writeFileSync8(path, JSON.stringify(value, null, 2) + `
216995
- `);
216996
- var projectRoot = () => {
216997
- try {
216998
- return findProjectRoot(process.cwd());
216999
- } catch {
217000
- return process.cwd();
217001
- }
217002
- };
217003
- var git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
217004
- var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "app";
217005
- var runTool = (cwd, cmd, args) => {
217006
- try {
217007
- return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 128 * 1024 * 1024 });
217008
- } catch (e) {
217009
- const err = e;
217010
- const tail = ((err.stdout ?? "") + `
217011
- ` + (err.stderr ?? "")).split(`
217012
- `).filter((l) => l.trim()).slice(-25).join(`
217013
- `);
217014
- throw new CliError(`${cmd} ${args[0] ?? ""} failed:
217015
- ${tail}`);
217016
- }
217017
- };
217018
- var ensureShellIgnored = (root, dir) => {
217019
- const ignorePath = join12(root, ".lecodesignore");
217020
- const ignore = existsSync10(ignorePath) ? readFileSync11(ignorePath, "utf8") : DEFAULT_IGNORE_FILE;
217021
- if (new RegExp(`^${dir}\\/$`, "m").test(ignore))
217022
- return;
217023
- const comment = ignore.includes("# Native app shells") ? "" : "# Native app shells — generated by `lecodes app init`, never pushed to LeCodes.\n";
217024
- writeFileSync8(ignorePath, ignore.replace(/\n*$/, `
217025
- `) + `
217026
- ` + comment + `${dir}/
217027
- `);
217028
- log(`${c.green("updated")} .lecodesignore (+ ${dir}/)`);
217029
- };
217030
- var SDK_VARIANTS = ["core", "2d", "3d", "full"];
217031
- var detectEngines = (bundleJs) => {
217032
- const lines = [];
217033
- for (const line of bundleJs.split(`
217034
- `)) {
217035
- if (!line.startsWith("//"))
217036
- break;
217037
- lines.push(line);
217038
- }
217039
- const header = lines.join(`
217040
- `);
217041
- return { gl: !/^\/\/ gl: disable$/m.test(header), g2: /^\/\/ 2d: enable$/m.test(header) };
217042
- };
217043
- var resolveVariant = (requested, bundleJs, configKey = "ios.variant") => {
217044
- const uses = detectEngines(bundleJs);
217045
- const detected = uses.gl && uses.g2 ? "full" : uses.gl ? "3d" : uses.g2 ? "2d" : "core";
217046
- if (requested === "auto")
217047
- return { variant: detected, detected };
217048
- const variant = requested;
217049
- const missing = [
217050
- uses.gl && variant !== "3d" && variant !== "full" ? "3D" : null,
217051
- uses.g2 && variant !== "2d" && variant !== "full" ? "2D" : null
217052
- ].filter(Boolean);
217053
- if (missing.length > 0) {
217054
- warn(`project uses the ${missing.join(" + ")} engine but ${configKey} forces "${variant}" — those scenes won't run on device; remove ${configKey} to auto-select "${detected}".`);
217055
- }
217056
- return { variant, detected };
217057
- };
217058
- var VERSION_RE = /^\d+\.\d+\.\d+$/;
217059
- var cmpVersion = (a, b) => {
217060
- const pa = a.split(".").map(Number);
217061
- const pb = b.split(".").map(Number);
217062
- for (let i = 0;i < 3; i++) {
217063
- if ((pa[i] ?? 0) !== (pb[i] ?? 0))
217064
- return (pa[i] ?? 0) - (pb[i] ?? 0);
217065
- }
217066
- return 0;
217067
- };
217068
- var fetchLatestSdkVersion = async (repo) => {
217069
- const url = `https://api.github.com/repos/${repo}/releases/latest`;
217070
- const res = await fetch(url, { headers: { accept: "application/vnd.github+json", "user-agent": "lecodes-cli" } });
217071
- if (!res.ok)
217072
- throw new CliError(`Could not read the latest ${repo} release (HTTP ${res.status}) — ${url}`);
217073
- const tag = (await res.json()).tag_name?.replace(/^v/, "") ?? "";
217074
- if (!VERSION_RE.test(tag))
217075
- throw new CliError(`${repo} latest release has no x.y.z tag (got "${tag}")`);
217076
- return tag;
217077
- };
217078
- var resolveSdkVersion = async (pin, fallback, opts) => {
217079
- if (pin !== "latest") {
217080
- if (!VERSION_RE.test(pin)) {
217081
- throw new CliError(`app.json ${opts.configKey} must be "latest" or an exact x.y.z release (got "${pin}").`);
217082
- }
217083
- if (cmpVersion(pin, opts.minVersion) < 0) {
217084
- throw new CliError(`app.json ${opts.configKey} ${pin} is older than the ${opts.minVersion} these generated shells need.`);
217085
- }
217086
- return pin;
217087
- }
217088
- try {
217089
- return await fetchLatestSdkVersion(opts.repo);
217090
- } catch (e) {
217091
- const kept = fallback ?? opts.minVersion;
217092
- warn(`Could not resolve the latest SDK (${e.message}) — staying on ${kept}.`);
217093
- return kept;
217094
- }
217095
- };
217096
- var ensureRepo = (repo, wantTag, lock) => {
217097
- const override = process.env.LECODES_PLUGINS_DIR;
217098
- if (override) {
217099
- note(`Using local plugin checkout ${override} (LECODES_PLUGINS_DIR)`);
217100
- return { dir: resolve2(override), tag: "local", commit: "local" };
217101
- }
217102
- const cache = join12(homedir3(), ".lecodes", "plugins", repo.replace("/", "__"));
217103
- const url = `https://github.com/${repo}.git`;
217104
- if (!existsSync10(join12(cache, ".git"))) {
217105
- mkdirSync6(dirname8(cache), { recursive: true });
217106
- note(`Cloning ${url}…`);
217107
- git(dirname8(cache), "clone", "--quiet", url, cache);
217108
- }
217109
- let tag = lock?.tag ?? wantTag;
217110
- let commit = lock?.commit;
217111
- if (commit) {
217112
- try {
217113
- git(cache, "cat-file", "-e", `${commit}^{commit}`);
217114
- } catch {
217115
- git(cache, "fetch", "--quiet", "--tags", "origin");
217116
- }
217117
- } else {
217118
- git(cache, "fetch", "--quiet", "--tags", "origin");
217119
- if (!tag) {
217120
- tag = git(cache, "tag", "--list", "v*", "--sort=-version:refname").split(`
217121
- `)[0] ?? "";
217122
- if (!tag)
217123
- throw new CliError(`${repo} has no v* version tags to install from`);
217124
- }
217125
- commit = git(cache, "rev-list", "-n", "1", tag);
217126
- }
217127
- git(cache, "checkout", "--quiet", "--detach", commit);
217128
- return { dir: cache, tag: tag ?? "unknown", commit };
217129
- };
217130
- var resolvePlugin = (id, spec, lock, platform) => {
217131
- let repo;
217132
- let subdir;
217133
- let wantTag = null;
217134
- if (spec.startsWith("github:")) {
217135
- const [repoPart, tagPart] = spec.slice("github:".length).split("@");
217136
- repo = repoPart;
217137
- wantTag = tagPart ?? null;
217138
- subdir = "";
217139
- } else {
217140
- repo = FIRST_PARTY_PLUGINS[id];
217141
- if (!repo) {
217142
- throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use a github:owner/repo spec.`);
217143
- }
217144
- subdir = `plugins/${id}`;
217145
- }
217146
- const checkout = ensureRepo(repo, wantTag, lock?.repo === repo ? lock : undefined);
217147
- const dir = subdir ? join12(checkout.dir, subdir) : checkout.dir;
217148
- const manifest = readJson(join12(dir, "lecodes-plugin.json"));
217149
- if (!manifest)
217150
- throw new CliError(`Plugin "${id}": no lecodes-plugin.json in ${dir}`);
217151
- if (manifest.id !== id)
217152
- throw new CliError(`Plugin "${id}": manifest declares id "${manifest.id}"`);
217153
- if (!manifest[platform]) {
217154
- throw new CliError(`Plugin "${id}" does not support ${platform === "ios" ? "iOS" : "Android"} (manifest "${platform}" is null)`);
217155
- }
217156
- if (spec.startsWith("github:")) {
217157
- const names = [...manifest.provides?.views ?? [], ...manifest.provides?.services ?? []];
217158
- for (const name of names) {
217159
- if (!name.includes("."))
217160
- warn(`Plugin "${id}" provides bare channel name "${name}" — third-party names should be vendor-prefixed (e.g. "acme.${name}").`);
217161
- }
217162
- }
217163
- return {
217164
- id,
217165
- dir,
217166
- manifest,
217167
- lock: { repo, tag: checkout.tag, commit: checkout.commit, version: manifest.version ?? "unknown" }
217168
- };
217051
+ `)},
217052
+ ],
217053
+ path: "Sources/LeCodesRuntime"
217054
+ ),${pluginTargets}
217055
+ ]
217056
+ )
217057
+ `;
217169
217058
  };
217170
- var latestPluginTag = (repo) => {
217171
- try {
217172
- return ensureRepo(repo, null, undefined).tag;
217173
- } catch {
217174
- return null;
217175
- }
217059
+ var runtimeSwiftTemplate = (plugins, projectUuid) => {
217060
+ const imports = plugins.map((p) => `import ${p.module}
217061
+ `).join("");
217062
+ const identity = !projectUuid ? "" : ` // World identity: this shell IS the project — the engine attributes the boot world so
217063
+ // identity-gated services (push registration) see the app's uuid from its first
217064
+ // synchronous statement. Baked from .lecodes/manifest.json by \`lecodes app sync\`.
217065
+ engine.bootProjectUuid = "${projectUuid}"
217066
+ `;
217067
+ const calls = plugins.map((p) => ` ${p.register}.register(in: engine)
217068
+ `).join("");
217069
+ const body = identity + calls;
217070
+ return `//
217071
+ // LeCodesRuntime.swift
217072
+ // GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
217073
+ //
217074
+ // Re-exports the SDK (so app code needs only \`import LeCodesRuntime\`) and registers
217075
+ // every plugin installed in app.json.
217076
+ //
217077
+
217078
+ @_exported import LeCodesSDK
217079
+ ${imports}
217080
+ public func registerBundledPlugins(in engine: LeCodesEngine) {
217081
+ ${body === "" ? `
217082
+ ` : body}}
217083
+ `;
217176
217084
  };
217085
+ var IOS_README = `# ios/ — native Xcode shell
217086
+
217087
+ Generated by \`lecodes app init ios\` for the LeCodes project one folder up. The app runs the
217088
+ compiled bundle in \`App/Resources/app.js\` — fully offline, nothing fetched at launch. This
217089
+ folder is ignored by the platform (\`ios/\` in \`.lecodesignore\`) and is never pushed to LeCodes.
217090
+
217091
+ ## Daily workflow
217092
+
217093
+ Edit the app's code at the project root (\`main.ts\` …), then:
217094
+
217095
+ \`\`\`
217096
+ lecodes app sync # recompile app.js + assets into the shell, vendor plugins
217097
+ lecodes app open # open App.xcodeproj in Xcode — build & run from there
217098
+ \`\`\`
217099
+
217100
+ \`lecodes app doctor\` preflights builds; \`lecodes app build ios\` archives and exports an
217101
+ .ipa; \`lecodes app upload ios\` sends the archive to App Store Connect.
217102
+
217103
+ ## SDK version
217104
+
217105
+ \`../app.json\` \`ios.sdk\` is the exact \`lecodes-ios-sdk\` release this shell links —
217106
+ \`lecodes app init\` pinned the newest one at the time, so builds stay reproducible.
217107
+
217108
+ \`\`\`
217109
+ lecodes app update # → newest SDK release + newest plugin tags, then sync
217110
+ lecodes app update --check # what's available, writes nothing
217111
+ lecodes app update --sdk 1.3.0
217112
+ \`\`\`
217113
+
217114
+ Set \`"sdk": "latest"\` instead of a version to re-resolve on every sync (needs network;
217115
+ offline it keeps whatever the shell already links). The pin is exact rather than a range
217116
+ because the CLI also downloads that release's filament resources bundle into
217117
+ \`App/Resources/\` — the two halves of one SDK must not drift apart.
217118
+
217119
+ ## Who owns what
217120
+
217121
+ Yours — \`lecodes app sync\` never touches these:
217122
+
217123
+ - \`App/*.swift\` (AppDelegate, SceneDelegate, ViewController) — customize freely, add files.
217124
+ App-local native code goes here too: add a Swift file that calls
217125
+ \`engine.registerCallback\` / \`registerService\`, and invoke it from ViewController.
217126
+ - \`App/Assets.xcassets/\` — the app icon. Easy path: set \`app.json\` \`"icon"\` to a square
217127
+ ≥1024px image and every sync renders AppIcon.png from it (flattened over \`"iconBackground"\`,
217128
+ default white — App Store icons must be opaque). Or leave the key unset and drop a
217129
+ 1024×1024 PNG into AppIcon.appiconset/ yourself — sync never touches it then.
217130
+ - \`App/Base.lproj/LaunchScreen.storyboard\`
217131
+
217132
+ CLI-owned — regenerated on every sync, hand edits are overwritten:
217133
+
217134
+ - \`App/Resources/\` — the compiled app.js + every asset it references (fonts included), so
217135
+ the app runs fully offline. Globs in app.json \`"resources": {"remote": [...]}\` opt heavy
217136
+ assets (videos, big models) back into streaming from the server instead.
217137
+ - \`App/Info.plist\` — template + the installed plugins' usage keys
217138
+ - \`App/App.entitlements\` — the capabilities the installed plugins declare (e.g. push)
217139
+ - \`LeCodesRuntime/\` — local Swift package: the LeCodesSDK dependency, vendored plugin
217140
+ sources (Sources/<Module>/), and the registerBundledPlugins entry point
217141
+ - the version values inside \`App.xcodeproj/project.pbxproj\` (from app.json)
217142
+ - this README
217143
+
217144
+ ## SDK variant
217145
+
217146
+ Sync links the smallest SDK product that covers what the compiled bundle actually uses:
217147
+ \`core\` (UI only), \`2d\` (+ 2D engine with physics), \`3d\` (+ 3D/AR engine with physics,
217148
+ ~17 MB heavier), or \`full\` (everything). Adding your first 3D scene or 2D game to the
217149
+ project switches it automatically on the next sync; set \`ios.variant\` in \`../app.json\`
217150
+ to pin one instead.
217151
+
217152
+ ## Over-the-air updates
217153
+
217154
+ With \`"update": true\` in \`../app.json\` (server-backed projects; or
217155
+ \`"update": {"url": "https://…/app.js"}\` for any static hosting), the app checks that URL
217156
+ at launch — natively, before any JS runs, so a broken bundle can never block its own fix.
217157
+ Unchanged code costs a ~0-byte 304; new code downloads in the background and runs on the
217158
+ NEXT launch. Offline it's a silent no-op — the app always launches from a local bundle.
217159
+ An App Store update always beats a cached OTA bundle. JS only: adding/removing native
217160
+ plugins still needs a store release.
217161
+
217162
+ Shipping an update is just \`lecodes push --publish\` — no rebuild, no store review:
217163
+ running apps download the new code on their next launch and run it on the launch after.
217164
+
217165
+ Shells generated before OTA support have a \`ViewController.swift\` that reads app.js from
217166
+ \`Bundle.main\` directly, so downloaded updates would never run — and since \`App/*.swift\`
217167
+ is yours, sync won't rewrite it (\`lecodes app doctor\` flags this). Replace the
217168
+ \`Bundle.main\` load with:
217169
+
217170
+ \`\`\`swift
217171
+ LeCodesUpdater.checkForUpdate()
217172
+ guard let code = LeCodesUpdater.activeBundle() else {
217173
+ print("LeCodes: bundled app.js is missing — run \`lecodes app sync\`")
217174
+ return
217175
+ }
217176
+ engine.run(code)
217177
+ \`\`\`
217178
+
217179
+ ## Config
217180
+
217181
+ Identity lives in \`../app.json\` (name, version, plugins, ios.bundleId / teamId / variant /
217182
+ buildNumber) — edit it there, then re-run \`lecodes app sync\`. Plugins are managed with
217183
+ \`lecodes app plugin add <id | github:owner/repo[@tag]>\` and pinned in \`../plugins.lock.json\`.
217184
+ `;
217177
217185
 
217178
217186
  // src/commands/appAndroid.ts
217179
217187
  init_manifest();
@@ -218692,6 +218700,106 @@ import { spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process";
218692
218700
  import { copyFileSync, existsSync as existsSync14, mkdirSync as mkdirSync9, mkdtempSync, readdirSync as readdirSync5, readFileSync as readFileSync14, renameSync as renameSync2, rmSync as rmSync2, statSync as statSync3, writeFileSync as writeFileSync11 } from "node:fs";
218693
218701
  import { homedir as homedir5, tmpdir } from "node:os";
218694
218702
  import { join as join15, resolve as resolve4 } from "node:path";
218703
+
218704
+ // ../viewer-lite/src/sourcemapError.ts
218705
+ var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
218706
+ var B64_INDEX = {};
218707
+ for (let i2 = 0;i2 < B64.length; i2++)
218708
+ B64_INDEX[B64[i2]] = i2;
218709
+ var decodeVlq = (segment) => {
218710
+ const out = [];
218711
+ let shift = 0, value = 0;
218712
+ for (const ch of segment) {
218713
+ const digit = B64_INDEX[ch];
218714
+ if (digit === undefined)
218715
+ break;
218716
+ const more = digit & 32;
218717
+ value += (digit & 31) << shift;
218718
+ if (more) {
218719
+ shift += 5;
218720
+ } else {
218721
+ const negative = value & 1;
218722
+ value >>= 1;
218723
+ out.push(negative ? -value : value);
218724
+ value = 0;
218725
+ shift = 0;
218726
+ }
218727
+ }
218728
+ return out;
218729
+ };
218730
+
218731
+ class SourceMap {
218732
+ sources;
218733
+ lines;
218734
+ constructor(raw) {
218735
+ this.sources = raw.sources;
218736
+ this.lines = [];
218737
+ let srcIdx = 0, srcLine = 0, srcCol = 0;
218738
+ for (const lineStr of raw.mappings.split(";")) {
218739
+ const segs = [];
218740
+ let genCol = 0;
218741
+ if (lineStr) {
218742
+ for (const segStr of lineStr.split(",")) {
218743
+ const v = decodeVlq(segStr);
218744
+ if (v.length === 0)
218745
+ continue;
218746
+ genCol += v[0];
218747
+ if (v.length >= 4) {
218748
+ srcIdx += v[1];
218749
+ srcLine += v[2];
218750
+ srcCol += v[3];
218751
+ segs.push([genCol, srcIdx, srcLine, srcCol]);
218752
+ }
218753
+ }
218754
+ }
218755
+ this.lines.push(segs);
218756
+ }
218757
+ }
218758
+ originalPositionFor(line, column) {
218759
+ const segs = this.lines[line - 1];
218760
+ if (!segs || segs.length === 0)
218761
+ return null;
218762
+ let best = null;
218763
+ for (const seg of segs) {
218764
+ if (seg[0] <= column)
218765
+ best = seg;
218766
+ else
218767
+ break;
218768
+ }
218769
+ best ??= segs[0];
218770
+ const source = this.sources[best[1]];
218771
+ if (source === undefined)
218772
+ return null;
218773
+ return { source, line: best[2] + 1, column: best[3] };
218774
+ }
218775
+ }
218776
+ var extractInlineSourceMap = (code) => {
218777
+ const m = code.match(/\/\/# lecodesMap=data:application\/json;(?:charset=[^;]+;)?base64,([A-Za-z0-9+/=]+)\s*$/);
218778
+ if (!m)
218779
+ return null;
218780
+ try {
218781
+ const json = typeof atob === "function" ? atob(m[1]) : Buffer.from(m[1], "base64").toString("utf8");
218782
+ return new SourceMap(JSON.parse(json));
218783
+ } catch {
218784
+ return null;
218785
+ }
218786
+ };
218787
+ var mapNamedFileStack = (text, map, filename) => {
218788
+ const escaped = filename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
218789
+ return text.replace(new RegExp(`\\b${escaped}:(\\d+):(\\d+)`, "g"), (whole, l2, c4) => {
218790
+ const orig = map.originalPositionFor(parseInt(l2, 10), Math.max(0, parseInt(c4, 10) - 1));
218791
+ return orig ? `${orig.source.replace(/^(\.\/|\/)/, "")}:${orig.line}:${orig.column}` : whole;
218792
+ });
218793
+ };
218794
+
218795
+ // src/compile/nativeStack.ts
218796
+ var BUNDLE_FILENAME = "module.js";
218797
+ var makeStackDemangler = (bundleJs) => {
218798
+ const map = extractInlineSourceMap(bundleJs);
218799
+ return map ? (chunk) => mapNamedFileStack(chunk, map, BUNDLE_FILENAME) : null;
218800
+ };
218801
+
218802
+ // src/desktopRenderer.ts
218695
218803
  var MANIFEST_URL = "https://storage.yandexcloud.net/le-codes/releases/versions.json";
218696
218804
  var DESKTOP_RENDERER_VERSION = "1.1.0";
218697
218805
  var semverNewer = (a2, b) => {
@@ -218864,13 +218972,34 @@ var stageBundle = (js, resources) => {
218864
218972
  }
218865
218973
  return runDir;
218866
218974
  };
218975
+ var pipeThroughDemangler = (src, dst, map) => {
218976
+ let rest = "";
218977
+ src.on("data", (d) => {
218978
+ rest += String(d);
218979
+ const nl = rest.lastIndexOf(`
218980
+ `);
218981
+ if (nl < 0)
218982
+ return;
218983
+ dst.write(map(rest.slice(0, nl + 1)));
218984
+ rest = rest.slice(nl + 1);
218985
+ });
218986
+ src.on("end", () => {
218987
+ if (rest)
218988
+ dst.write(map(rest));
218989
+ });
218990
+ };
218867
218991
  var runDesktopApp = async (js, resources, opts = {}) => {
218868
218992
  const exe = await resolveDesktopExe();
218869
218993
  const runDir = stageBundle(js, resources);
218870
218994
  try {
218871
218995
  const env = { ...process.env, ...windowEnv(opts) };
218996
+ const demangle = makeStackDemangler(js);
218872
218997
  await new Promise((done, fail) => {
218873
- const child = spawn2(exe, [join15(runDir, "app.js")], { cwd: runDir, env, stdio: ["ignore", "inherit", "inherit"] });
218998
+ const child = demangle ? spawn2(exe, [join15(runDir, "app.js")], { cwd: runDir, env, stdio: ["ignore", "pipe", "pipe"] }) : spawn2(exe, [join15(runDir, "app.js")], { cwd: runDir, env, stdio: ["ignore", "inherit", "inherit"] });
218999
+ if (demangle) {
219000
+ pipeThroughDemangler(child.stdout, process.stdout, demangle);
219001
+ pipeThroughDemangler(child.stderr, process.stderr, demangle);
219002
+ }
218874
219003
  child.on("error", (e2) => fail(new CliError(`Can't run the desktop host: ${e2.message}`)));
218875
219004
  child.on("exit", (code) => {
218876
219005
  if (code === 0 || code === null)
@@ -218898,26 +219027,33 @@ var runDesktopRender = async (js, resources, opts) => {
218898
219027
  try {
218899
219028
  const appJs = join15(runDir, "app.js");
218900
219029
  const outAbs = resolve4(opts.outPng);
218901
- await new Promise((done, fail) => {
218902
- const child = spawn2(exe, [appJs], {
218903
- cwd: runDir,
218904
- env: {
218905
- ...process.env,
218906
- CREATOR_DUMP_PNG: outAbs,
218907
- CREATOR_MAX_FRAMES: String(opts.frames),
218908
- CREATOR_WINDOW_SIZE: `${opts.width}x${opts.height}`,
218909
- CREATOR_HIDDEN: "1"
218910
- },
218911
- stdio: ["ignore", "pipe", "pipe"]
218912
- });
218913
- const tail = [];
219030
+ const env = {
219031
+ ...process.env,
219032
+ CREATOR_DUMP_PNG: outAbs,
219033
+ CREATOR_MAX_FRAMES: String(opts.frames),
219034
+ CREATOR_WINDOW_SIZE: `${opts.width}x${opts.height}`,
219035
+ CREATOR_HIDDEN: "1"
219036
+ };
219037
+ if (opts.script !== undefined) {
219038
+ const scriptPath = join15(runDir, "script.txt");
219039
+ writeFileSync11(scriptPath, opts.script);
219040
+ env.CREATOR_SCRIPT = scriptPath;
219041
+ }
219042
+ if (opts.fixedDtMs)
219043
+ env.CREATOR_FIXED_DT = String(opts.fixedDtMs);
219044
+ if (opts.camera)
219045
+ env.CREATOR_CAMERA = opts.camera;
219046
+ if (opts.view)
219047
+ env.CREATOR_VIEW = opts.view;
219048
+ const demangle = makeStackDemangler(js) ?? ((text) => text);
219049
+ return await new Promise((done, fail) => {
219050
+ const child = spawn2(exe, [appJs], { cwd: runDir, env, stdio: ["ignore", "pipe", "pipe"] });
219051
+ const chunks = [];
218914
219052
  const sink = (d) => {
218915
219053
  const text = String(d);
218916
- tail.push(text);
218917
- while (tail.length > 80)
218918
- tail.shift();
219054
+ chunks.push(text);
218919
219055
  if (opts.logs)
218920
- process.stderr.write(text);
219056
+ process.stderr.write(demangle(text));
218921
219057
  };
218922
219058
  child.stdout.on("data", sink);
218923
219059
  child.stderr.on("data", sink);
@@ -218931,10 +219067,12 @@ var runDesktopRender = async (js, resources, opts) => {
218931
219067
  });
218932
219068
  child.on("exit", (code) => {
218933
219069
  clearTimeout(killer);
219070
+ const output = demangle(chunks.join(""));
218934
219071
  if (code === 0 && existsSync14(outAbs))
218935
- return done();
219072
+ return done({ output, exitCode: code });
219073
+ const tail = demangle(chunks.slice(-80).join("")).trimEnd();
218936
219074
  const detail = tail.length > 0 ? `
218937
- ${tail.join("").trimEnd()}` : "";
219075
+ ${tail}` : "";
218938
219076
  fail(new CliError(code === 0 ? `The renderer exited cleanly but produced no PNG — did the app open a screen?${detail}` : `The renderer exited with code ${code}.${detail}`));
218939
219077
  });
218940
219078
  });
@@ -219980,12 +220118,22 @@ from \`main.ts\`.
219980
220118
  \`\`\`
219981
220119
  lecodes render # semantic JSON of the launched UI (structure, text, names, values)
219982
220120
  lecodes render --png shot.png # pixels (needs @napi-rs/canvas installed)
220121
+ lecodes render --png card.png --clip card # ONE component/region: a node name or x,y,w,h
219983
220122
  lecodes render --desktop --png shot.png # NATIVE render: real Filament 3D + UI + 2D composited —
219984
220123
  # use this to SEE 3D scenes / procedural geometry (headless
219985
220124
  # renders record 3D as JSON only). Windows/Linux; downloads
219986
220125
  # the renderer on first use. --frames <n> for slow loads.
219987
220126
  lecodes render --script <file> # drive the app through a scenario (format below)
220127
+ lecodes render --desktop --view iso --png iso.png # 3D: inspection camera fitted to the WHOLE
220128
+ # scene (iso|top|front|side|back [--fov N]) — or --camera x,y,z
220129
+ # --look-at x,y,z; the app's camera is untouched. In scripts:
220130
+ # { "view": "iso", "screenshot": "iso.png" } then { "view": "off" }
220131
+ lecodes render --desktop --script <file> # the same scenario on the NATIVE host (3D/Jolt, real
220132
+ # GPU): coordinates only ({ x, y }), expect.log checked,
220133
+ # text/node/screen expects skipped; deterministic 16 ms frames
219988
220134
  lecodes test # all tests/*.flow.json + design-derived flows; run before "done"
220135
+ lecodes test --desktop # the file flows on the NATIVE host (3D/Jolt/real GPU; coordinate
220136
+ # flows only — design flows stay headless)
219989
220137
  \`\`\`
219990
220138
 
219991
220139
  Iterate with \`--script\` scenarios and keep the useful ones as \`tests/<name>.flow.json\` — they
@@ -219994,19 +220142,32 @@ are the regression suite. A failing step dumps the failing frame (JSON + PNG) un
219994
220142
 
219995
220143
  ## Scenario format (tests/*.flow.json and --script files)
219996
220144
 
219997
- One action per step (\`tap\`, \`type\`+\`text\`, \`fill\`, \`scroll\` [+\`by\`], \`wait\`), then an
220145
+ One action per step (\`tap\`, \`type\`+\`text\`, \`fill\`, \`scroll\` [+\`by\`], \`wait\`, and for
220146
+ games: \`frames\` (advance N fixed 1/60 steps, no sleeping), \`drag\`+\`to\` [+\`ms\`], \`hold\`+\`ms\`,
220147
+ \`key\`+\`ms\` / \`keyDown\` / \`keyUp\` (KeyboardEvent.code: "ArrowRight", "KeyW", "Space")), then an
219998
220148
  optional \`expect\` (\`screen\`, \`node\`/\`notNode\`, \`text\`/\`notText\`,
219999
- \`value: { target, equals }\`), then outputs (\`screenshot\`, \`json\`). Targets resolve by
220000
- \`name\` → exact visible text → substring → input placeholder. Envelope keys: \`name\`,
220149
+ \`value: { target, equals }\`, \`log\` — a \`console.log\` line contains it), then outputs
220150
+ (\`screenshot\` [+\`clip\`: a node name or \`{ x, y, width, height }\`], \`json\`). Targets resolve by
220151
+ \`name\` → exact visible text → substring → input placeholder; a tap/drag on empty space reaches
220152
+ the 2D scene (sprite \`click\`/\`touchstart\`, \`ev.track()\`). Envelope keys: \`name\`,
220001
220153
  \`width\`/\`height\`, \`settle\`, \`fixtures\`, \`steps\`.
220002
220154
 
220003
220155
  \`\`\`json
220004
220156
  { "steps": [
220005
220157
  { "expect": { "screen": "home", "text": "Taps: 0" } },
220006
- { "tap": "tap", "expect": { "text": "Taps: 1" }, "screenshot": "after.png" }
220158
+ { "tap": "tap", "expect": { "text": "Taps: 1" }, "screenshot": "after.png" },
220159
+ { "key": "ArrowRight", "ms": 500, "expect": { "log": "hero x=" } },
220160
+ { "frames": 60, "screenshot": "hud.png", "clip": "hud" }
220007
220161
  ] }
220008
220162
  \`\`\`
220009
220163
 
220164
+ Game time is deterministic headless: \`setLoop\`, 2D physics/animation and aspect \`update(dt)\`
220165
+ advance in fixed 1/60 steps driven by \`wait\`/\`frames\`/\`drag\`/\`key\` — never by a wall clock —
220166
+ so a flow that passes once passes every time. \`Input.key(code)\` reads the keys a flow holds.
220167
+ Headless draws UI + the 2D scene (sprites, tilemaps, Box2D); 3D is captured as JSON and Jolt does
220168
+ not simulate — use \`--desktop\` for 3D pixels. \`console.log\` + \`expect.log\` is the assertion
220169
+ channel for game state that has no UI.
220170
+
220010
220171
  ## Make the app testable
220011
220172
 
220012
220173
  - Give interactive elements a \`name\`: \`UIButton(...).style({ name: "login" })\`.
@@ -220116,7 +220277,7 @@ var run = async (args) => {
220116
220277
  }
220117
220278
  const appCfg = readAppConfigAt(root);
220118
220279
  const win = appCfg?.desktop?.window;
220119
- const { js, resources } = await buildBundle(args);
220280
+ const { js, resources } = await buildBundle(args, undefined, { localAssets: true });
220120
220281
  note("Launching LeCodes Desktop… (close the window to return)");
220121
220282
  await runDesktopApp(js, resources, {
220122
220283
  width: Number(flagStr(args, "width")) || win?.width,
@@ -220207,23 +220368,29 @@ var DEV_PRELUDE_JS = `
220207
220368
  } catch (e) {}
220208
220369
  }
220209
220370
 
220371
+ // An Error carries its stack (JSON.stringify would send a useless "{}"); the dev server maps the
220372
+ // frames back to project positions before printing.
220373
+ function describe(a) {
220374
+ if (typeof a === "string") return a
220375
+ if (a && typeof a.stack === "string" && typeof a.message === "string") {
220376
+ return a.stack ? String(a) + "\\n" + a.stack : String(a)
220377
+ }
220378
+ try { return JSON.stringify(a) } catch (e) { return String(a) }
220379
+ }
220380
+
220210
220381
  // Mirror console output to the dev terminal ('silent' guards recursion through wrapped calls).
220211
220382
  var silent = false
220212
220383
  ;["log", "warn", "error"].forEach(function (level) {
220213
220384
  var orig = console[level]
220214
220385
  console[level] = function () {
220215
220386
  var parts = []
220216
- for (var i = 0; i < arguments.length; i++) {
220217
- var a = arguments[i]
220218
- if (typeof a === "string") { parts.push(a); continue }
220219
- try { parts.push(JSON.stringify(a)) } catch (e) { parts.push(String(a)) }
220220
- }
220387
+ for (var i = 0; i < arguments.length; i++) parts.push(describe(arguments[i]))
220221
220388
  try { orig.apply(console, arguments) } catch (e) {}
220222
220389
  if (!silent) { silent = true; try { post({ type: "log", level: level, text: parts.join(" ") }) } finally { silent = false } }
220223
220390
  }
220224
220391
  })
220225
220392
 
220226
- function report(err) { post({ type: "error", text: String(err) }) }
220393
+ function report(err) { post({ type: "error", text: describe(err) }) }
220227
220394
 
220228
220395
  function load(v) {
220229
220396
  if (busy) { queued = v; return }
@@ -220501,6 +220668,14 @@ var startDevServer = async (options) => {
220501
220668
  for (const client of clients)
220502
220669
  client.send(text);
220503
220670
  };
220671
+ let demangleFor = null;
220672
+ const demangle = (text) => {
220673
+ if (bundleJs === null)
220674
+ return text;
220675
+ if (demangleFor?.js !== bundleJs)
220676
+ demangleFor = { js: bundleJs, map: makeStackDemangler(bundleJs) };
220677
+ return demangleFor.map ? demangleFor.map(text) : text;
220678
+ };
220504
220679
  const printDeviceMessage = (raw) => {
220505
220680
  let m = {};
220506
220681
  try {
@@ -220509,9 +220684,9 @@ var startDevServer = async (options) => {
220509
220684
  return;
220510
220685
  }
220511
220686
  if (m.type === "log")
220512
- log2(`[device:${m.level ?? "log"}] ${m.text ?? ""}`);
220687
+ log2(`[device:${m.level ?? "log"}] ${demangle(m.text ?? "")}`);
220513
220688
  else if (m.type === "error")
220514
- log2(`[device:error] ${m.text ?? ""}`);
220689
+ log2(`[device:error] ${demangle(m.text ?? "")}`);
220515
220690
  };
220516
220691
  const transportOf = (url) => url.searchParams.get("t") === "poll" ? "poll" : "ws";
220517
220692
  const handle = async (req, res) => {
@@ -221403,7 +221578,7 @@ var login = async (args) => {
221403
221578
 
221404
221579
  // src/commands/render.ts
221405
221580
  import { mkdirSync as mkdirSync18, readFileSync as readFileSync22, writeFileSync as writeFileSync19 } from "node:fs";
221406
- import { dirname as dirname15, resolve as resolve11 } from "node:path";
221581
+ import { dirname as dirname15, resolve as resolve12 } from "node:path";
221407
221582
 
221408
221583
  // src/compile/headlessBundle.ts
221409
221584
  init_api();
@@ -221484,6 +221659,240 @@ var compileHeadlessBundle = async (args) => {
221484
221659
 
221485
221660
  // src/commands/render.ts
221486
221661
  init_manifest();
221662
+
221663
+ // src/desktopScript.ts
221664
+ init_util();
221665
+ import { resolve as resolve11 } from "node:path";
221666
+ var isPoint = (v) => typeof v === "object" && v !== null && typeof v.x === "number" && typeof v.y === "number";
221667
+ var FRAME_MS = 16;
221668
+ var ACTIONS = ["tap", "type", "fill", "scroll", "wait", "frames", "drag", "hold", "key", "keyDown", "keyUp", "camera", "view"];
221669
+ var SEMANTIC_EXPECT = ["text", "notText", "node", "notNode", "screen", "value"];
221670
+ var VIEW_NAMES = ["iso", "top", "front", "side", "back"];
221671
+ var isVec3 = (v) => Array.isArray(v) && v.length === 3 && v.every((n2) => typeof n2 === "number");
221672
+ var pt = (p2) => `(${p2.x},${p2.y})`;
221673
+ var translateScenario = (rawSteps, opts) => {
221674
+ if (!Array.isArray(rawSteps))
221675
+ throw new CliError(`"steps" must be an array of step objects.`);
221676
+ const lines = [];
221677
+ const steps = [];
221678
+ let budgetMs = 0;
221679
+ const emit = (line) => {
221680
+ lines.push(line);
221681
+ return lines.length;
221682
+ };
221683
+ emit(`# generated by lecodes render --desktop --script`);
221684
+ emit(`frames ${Math.max(0, opts.headroomFrames)}`);
221685
+ const coord = (at, key, v) => {
221686
+ if (isPoint(v))
221687
+ return v;
221688
+ if (typeof v === "string") {
221689
+ throw new CliError(`${at}: "${key}": "${v}" — the native host can't resolve names; give --desktop scripts { "x", "y" } coordinates (logical px), or run the scenario headless (without --desktop).`);
221690
+ }
221691
+ throw new CliError(`${at}: "${key}" must be { "x", "y" }.`);
221692
+ };
221693
+ const num = (at, key, v, def) => {
221694
+ if (v === undefined && def !== undefined)
221695
+ return def;
221696
+ if (typeof v !== "number")
221697
+ throw new CliError(`${at}: "${key}" must be a number.`);
221698
+ return v;
221699
+ };
221700
+ let inspecting = false;
221701
+ rawSteps.forEach((step, i2) => {
221702
+ const at = `steps[${i2}]`;
221703
+ if (typeof step !== "object" || step === null || Array.isArray(step))
221704
+ throw new CliError(`${at}: must be an object.`);
221705
+ const s2 = step;
221706
+ const actions = ACTIONS.filter((k) => s2[k] !== undefined);
221707
+ if (actions.length > 1)
221708
+ throw new CliError(`${at}: one action per step (found ${actions.join(" + ")}).`);
221709
+ const plan = { index: i2, desc: "", lines: [], skippedExpect: [], skippedOutputs: [] };
221710
+ const parts = [];
221711
+ const action = actions[0];
221712
+ if (action === "type" || action === "fill" || action === "scroll") {
221713
+ throw new CliError(`${at}: "${action}" isn't available with --desktop (the native host has no semantic tree / soft keyboard) — run this scenario headless, or drive the gesture with "drag" coordinates.`);
221714
+ }
221715
+ if ((action === "tap" || action === "drag" || action === "hold") && inspecting) {
221716
+ throw new CliError(`${at}: "${action}" while an inspection camera is active — the app's hit-testing uses its own camera, not the one in the shot. Add { "view": "off" } (or { "camera": "off" }) first.`);
221717
+ }
221718
+ if (action === "view") {
221719
+ const v = s2.view;
221720
+ const fov = s2.fov !== undefined ? num(at, "fov", s2.fov) : undefined;
221721
+ if (v === "off") {
221722
+ plan.lines.push(emit("view off"));
221723
+ inspecting = false;
221724
+ parts.push("view off");
221725
+ } else if (typeof v === "string" && VIEW_NAMES.includes(v)) {
221726
+ plan.lines.push(emit(`view ${v}${fov !== undefined ? ` ${fov}` : ""}`));
221727
+ inspecting = true;
221728
+ parts.push(`view ${v}${fov !== undefined ? ` (fov ${fov})` : ""}`);
221729
+ } else
221730
+ throw new CliError(`${at}: "view" wants one of ${VIEW_NAMES.join(", ")} or "off".`);
221731
+ budgetMs += 2 * FRAME_MS;
221732
+ } else if (action === "camera") {
221733
+ const cam = s2.camera;
221734
+ if (cam === "off") {
221735
+ plan.lines.push(emit("camera off"));
221736
+ inspecting = false;
221737
+ parts.push("camera off");
221738
+ } else if (typeof cam === "object" && cam !== null && isVec3(cam.at) && isVec3(cam.lookAt)) {
221739
+ const { at: eye, lookAt, fov } = cam;
221740
+ const fovN = fov !== undefined ? num(at, "camera.fov", fov) : undefined;
221741
+ plan.lines.push(emit(`camera ${eye.join(" ")} ${lookAt.join(" ")}${fovN !== undefined ? ` ${fovN}` : ""}`));
221742
+ inspecting = true;
221743
+ parts.push(`camera at (${eye.join(",")}) → (${lookAt.join(",")})`);
221744
+ } else
221745
+ throw new CliError(`${at}: "camera" wants { "at": [x,y,z], "lookAt": [x,y,z], "fov"? } or "off".`);
221746
+ budgetMs += 2 * FRAME_MS;
221747
+ }
221748
+ if (s2.fov !== undefined && action !== "view")
221749
+ throw new CliError(`${at}: "fov" only goes with "view" (inside "camera" use camera.fov).`);
221750
+ if (action === "tap") {
221751
+ const p2 = coord(at, "tap", s2.tap);
221752
+ plan.lines.push(emit(`tap ${p2.x} ${p2.y}`));
221753
+ budgetMs += 4 * FRAME_MS;
221754
+ parts.push(`tap ${pt(p2)}`);
221755
+ } else if (action === "drag") {
221756
+ const a2 = coord(at, "drag", s2.drag), b = coord(at, "to", s2.to);
221757
+ const ms = num(at, "ms", s2.ms, 300);
221758
+ plan.lines.push(emit(`drag ${a2.x} ${a2.y} ${b.x} ${b.y} ${Math.round(ms)}`));
221759
+ budgetMs += ms + 4 * FRAME_MS;
221760
+ parts.push(`drag ${pt(a2)} → ${pt(b)} over ${ms}ms`);
221761
+ } else if (action === "hold") {
221762
+ const p2 = coord(at, "hold", s2.hold);
221763
+ const ms = num(at, "ms", s2.ms);
221764
+ plan.lines.push(emit(`hold ${p2.x} ${p2.y} ${Math.round(ms)}`));
221765
+ budgetMs += ms + 4 * FRAME_MS;
221766
+ parts.push(`hold ${pt(p2)} ${ms}ms`);
221767
+ } else if (action === "key") {
221768
+ if (typeof s2.key !== "string")
221769
+ throw new CliError(`${at}: "key" must be a KeyboardEvent.code string.`);
221770
+ const ms = num(at, "ms", s2.ms);
221771
+ plan.lines.push(emit(`key ${s2.key} ${Math.round(ms)}`));
221772
+ budgetMs += ms + 2 * FRAME_MS;
221773
+ parts.push(`key ${s2.key} ${ms}ms`);
221774
+ } else if (action === "keyDown" || action === "keyUp") {
221775
+ const code = s2[action];
221776
+ if (typeof code !== "string")
221777
+ throw new CliError(`${at}: "${action}" must be a code string.`);
221778
+ plan.lines.push(emit(`${action.toLowerCase()} ${code}`));
221779
+ budgetMs += 2 * FRAME_MS;
221780
+ parts.push(`${action} ${code}`);
221781
+ } else if (action === "wait") {
221782
+ const ms = num(at, "wait", s2.wait);
221783
+ plan.lines.push(emit(`wait ${Math.round(ms)}`));
221784
+ budgetMs += ms + FRAME_MS;
221785
+ parts.push(`wait ${ms}ms`);
221786
+ } else if (action === "frames") {
221787
+ const n2 = num(at, "frames", s2.frames);
221788
+ plan.lines.push(emit(`frames ${Math.round(n2)}`));
221789
+ budgetMs += n2 * FRAME_MS + FRAME_MS;
221790
+ parts.push(`advance ${n2} frames`);
221791
+ }
221792
+ if (s2.expect !== undefined) {
221793
+ if (typeof s2.expect !== "object" || s2.expect === null)
221794
+ throw new CliError(`${at}: "expect" must be an object.`);
221795
+ const e2 = s2.expect;
221796
+ const bits = [];
221797
+ for (const k of Object.keys(e2)) {
221798
+ if (k === "log") {
221799
+ if (typeof e2.log !== "string")
221800
+ throw new CliError(`${at}: expect.log must be a string.`);
221801
+ plan.expectLog = e2.log;
221802
+ bits.push(`log~"${e2.log}"`);
221803
+ } else if (SEMANTIC_EXPECT.includes(k)) {
221804
+ plan.skippedExpect.push(k);
221805
+ } else {
221806
+ throw new CliError(`${at}: unknown expect key "${k}".`);
221807
+ }
221808
+ }
221809
+ if (bits.length > 0)
221810
+ parts.push(`expect ${bits.join(" · ")}`);
221811
+ }
221812
+ if (s2.clip !== undefined)
221813
+ throw new CliError(`${at}: "clip" isn't available with --desktop (the native host dumps the whole window).`);
221814
+ if (s2.screenshot !== undefined) {
221815
+ if (typeof s2.screenshot !== "string")
221816
+ throw new CliError(`${at}: "screenshot" must be a path.`);
221817
+ plan.screenshot = resolve11(opts.outDir, s2.screenshot);
221818
+ plan.lines.push(emit(`frames 2`));
221819
+ plan.lines.push(emit(`shot ${plan.screenshot}`));
221820
+ budgetMs += 3 * FRAME_MS;
221821
+ parts.push(`→ ${s2.screenshot}`);
221822
+ }
221823
+ if (s2.json !== undefined)
221824
+ plan.skippedOutputs.push("json");
221825
+ if (plan.lines.length === 0) {
221826
+ plan.lines.push(emit(`frames 1`));
221827
+ budgetMs += FRAME_MS;
221828
+ }
221829
+ plan.desc = typeof s2.name === "string" ? s2.name : parts.join(", ") || "(empty step)";
221830
+ steps.push(plan);
221831
+ });
221832
+ const frameCap = Math.max(0, opts.headroomFrames) + Math.ceil(budgetMs / FRAME_MS) + 600;
221833
+ return { text: lines.join(`
221834
+ `) + `
221835
+ `, steps, frameCap };
221836
+ };
221837
+ var evaluateDesktopRun = (plan, output) => {
221838
+ const lines = output.split(/\r?\n/);
221839
+ const doneAt = new Map;
221840
+ const consoleLines = [];
221841
+ let loaded = false, ended = false;
221842
+ for (const line of lines) {
221843
+ const m = /^\[script\] #(\d+) (done|error) (\S+)(?:: (.*))?$/.exec(line);
221844
+ if (m) {
221845
+ doneAt.set(Number(m[1]), { at: consoleLines.length, error: m[2] === "error" ? `${m[3]}: ${m[4] ?? "failed"}` : undefined });
221846
+ continue;
221847
+ }
221848
+ if (line.startsWith("[script] loaded")) {
221849
+ loaded = true;
221850
+ continue;
221851
+ }
221852
+ if (line === "[script] end") {
221853
+ ended = true;
221854
+ continue;
221855
+ }
221856
+ if (line.startsWith("[script]"))
221857
+ continue;
221858
+ consoleLines.push(line);
221859
+ }
221860
+ const steps = [];
221861
+ let ok = true;
221862
+ for (const st of plan.steps) {
221863
+ const skipped = [
221864
+ ...st.skippedExpect.map((k) => `expect.${k} (no semantic tree on --desktop)`),
221865
+ ...st.skippedOutputs.map((k) => `${k} output (no semantic tree on --desktop)`)
221866
+ ];
221867
+ let error;
221868
+ let cutoff = consoleLines.length;
221869
+ for (const ln of st.lines) {
221870
+ const d = doneAt.get(ln);
221871
+ if (!d) {
221872
+ error = error ?? "the host never completed this step (crashed, closed, or hit the frame cap before it)";
221873
+ break;
221874
+ }
221875
+ if (d.error) {
221876
+ error = error ?? d.error;
221877
+ break;
221878
+ }
221879
+ cutoff = d.at;
221880
+ }
221881
+ if (!error && st.expectLog !== undefined) {
221882
+ const seen = consoleLines.slice(0, cutoff);
221883
+ if (!seen.some((l2) => l2.includes(st.expectLog))) {
221884
+ const tail = seen.filter((l2) => l2.startsWith("[DEBUG]") || l2.startsWith("[INFO]") || l2.startsWith("[WARN]") || l2.startsWith("[ERROR]")).slice(-3);
221885
+ error = `expected a console line containing "${st.expectLog}" — ${tail.length === 0 ? "the app logged nothing" : `last lines: ${tail.map((l2) => `"${l2}"`).join(", ")}`}`;
221886
+ }
221887
+ }
221888
+ if (error)
221889
+ ok = false;
221890
+ steps.push({ desc: st.desc, ok: !error, error, skipped });
221891
+ }
221892
+ return { loaded, ended, ok: ok && loaded, steps };
221893
+ };
221894
+
221895
+ // src/commands/render.ts
221487
221896
  init_util();
221488
221897
  var DEVICE_SAFE_AREA = {
221489
221898
  none: { insets: [0, 0, 0, 0], bars: [] },
@@ -221514,7 +221923,7 @@ var requireRenderer = async () => {
221514
221923
  };
221515
221924
  var fileScenarioIO = (dir) => ({
221516
221925
  write: (relPath, data) => {
221517
- const abs = resolve11(dir, relPath);
221926
+ const abs = resolve12(dir, relPath);
221518
221927
  mkdirSync18(dirname15(abs), { recursive: true });
221519
221928
  writeFileSync19(abs, data);
221520
221929
  }
@@ -221524,7 +221933,7 @@ var resolveFixtures = (value, baseDir) => {
221524
221933
  return;
221525
221934
  let map = value;
221526
221935
  if (typeof value === "string") {
221527
- const abs = resolve11(baseDir, value);
221936
+ const abs = resolve12(baseDir, value);
221528
221937
  try {
221529
221938
  map = JSON.parse(readFileSync22(abs, "utf8"));
221530
221939
  } catch (e2) {
@@ -221539,7 +221948,7 @@ var resolveFixtures = (value, baseDir) => {
221539
221948
  var readScenarioFile = (path2) => {
221540
221949
  let raw;
221541
221950
  try {
221542
- raw = JSON.parse(readFileSync22(resolve11(path2), "utf8"));
221951
+ raw = JSON.parse(readFileSync22(resolve12(path2), "utf8"));
221543
221952
  } catch (e2) {
221544
221953
  throw new CliError(`Can't read scenario "${path2}": ${e2 instanceof Error ? e2.message : String(e2)}`);
221545
221954
  }
@@ -221552,6 +221961,20 @@ var readScenarioFile = (path2) => {
221552
221961
  throw new CliError(`Scenario "${path2}" has no "steps".`);
221553
221962
  return file;
221554
221963
  };
221964
+ var parseClip = (raw) => {
221965
+ if (raw === undefined)
221966
+ return;
221967
+ const parts = raw.split(",").map((p2) => p2.trim());
221968
+ if (parts.length === 4 && parts.every((p2) => /^-?\d+(\.\d+)?$/.test(p2))) {
221969
+ const [x2, y2, width, height] = parts.map(Number);
221970
+ if (width <= 0 || height <= 0)
221971
+ throw new CliError(`--clip: width and height must be positive (got "${raw}").`);
221972
+ return { x: x2, y: y2, width, height };
221973
+ }
221974
+ if (parts.length === 1 && raw.trim() !== "")
221975
+ return raw.trim();
221976
+ throw new CliError(`--clip: expected a node name or "x,y,width,height" (got "${raw}").`);
221977
+ };
221555
221978
  var renderDesktop = async (args) => {
221556
221979
  let root;
221557
221980
  try {
@@ -221560,22 +221983,101 @@ var renderDesktop = async (args) => {
221560
221983
  root = process.cwd();
221561
221984
  }
221562
221985
  const win = readAppConfigAt(root)?.desktop?.window;
221563
- const width = Number(flagStr(args, "width")) || win?.width || 390;
221564
- const height = Number(flagStr(args, "height")) || win?.height || 844;
221986
+ const scriptFlag = flagStr(args, "script");
221987
+ const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null;
221988
+ const width = Number(flagStr(args, "width")) || scenario?.width || win?.width || 390;
221989
+ const height = Number(flagStr(args, "height")) || scenario?.height || win?.height || 844;
221565
221990
  const frames = Number(flagStr(args, "frames")) || 60;
221566
- const timeoutMs = Number(flagStr(args, "timeout")) || 60000;
221567
221991
  const pngFlag = flagStr(args, "png") ?? "screenshot.png";
221568
- const { js, resources } = await buildBundle(args);
221992
+ const logs = flagBool(args, "logs");
221993
+ const fixedDtFlag = flagStr(args, "fixed-dt");
221994
+ const fixedDtMs = fixedDtFlag !== undefined ? Number(fixedDtFlag) : scenario ? 16 : 0;
221995
+ if (!Number.isFinite(fixedDtMs) || fixedDtMs < 0 || fixedDtMs > 1000)
221996
+ throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${fixedDtFlag}".`);
221997
+ const { camera, view } = resolveInspectionCamera(args);
221998
+ if ((camera || view) && scenario)
221999
+ throw new CliError('--camera/--view apply to a plain render; in a --script run use a { "view": "iso" } or { "camera": { "at", "lookAt" } } step before the screenshot.');
222000
+ const { js, resources } = await buildBundle(args, undefined, { localAssets: true });
222001
+ mkdirSync18(dirname15(resolve12(pngFlag)), { recursive: true });
222002
+ if (scenario) {
222003
+ const outDir = flagStr(args, "out-dir") ?? ".";
222004
+ const plan = translateScenario(scenario.steps, { outDir, headroomFrames: frames });
222005
+ for (const st of plan.steps)
222006
+ if (st.screenshot)
222007
+ mkdirSync18(dirname15(st.screenshot), { recursive: true });
222008
+ const timeoutMs2 = Number(flagStr(args, "timeout")) || 60000 + plan.steps.length * 5000;
222009
+ note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (native)…`);
222010
+ const { output } = await runDesktopRender(js, resources, {
222011
+ width,
222012
+ height,
222013
+ frames: plan.frameCap,
222014
+ outPng: pngFlag,
222015
+ logs,
222016
+ timeoutMs: timeoutMs2,
222017
+ script: plan.text,
222018
+ fixedDtMs
222019
+ });
222020
+ const result = evaluateDesktopRun(plan, output);
222021
+ if (!result.loaded) {
222022
+ throw new CliError("This desktop host doesn't support scripted input (CREATOR_SCRIPT) — it ran the frames and ignored the steps. Update it: lecodes desktop update (or point LECODES_DESKTOP_EXE at a newer build).");
222023
+ }
222024
+ for (const st of result.steps) {
222025
+ logErr(st.ok ? `${c.green("✓")} ${st.desc}` : `${c.red("✗")} ${st.desc}
222026
+ ${c.red(st.error ?? "failed")}`);
222027
+ for (const sk of st.skipped)
222028
+ logErr(c.dim(` – skipped: ${sk}`));
222029
+ }
222030
+ const skippedCount = result.steps.reduce((n2, s2) => n2 + s2.skipped.length, 0);
222031
+ if (skippedCount > 0)
222032
+ logErr(c.dim(` ${skippedCount} semantic check${skippedCount === 1 ? "" : "s"} skipped — the native host has no UI tree; run the same scenario headless to verify them.`));
222033
+ if (!result.ended)
222034
+ warnErr("The host stopped before the script finished (frame cap / crash) — later steps are reported as incomplete.");
222035
+ logErr(result.ok ? c.green(`✓ ${result.steps.length}/${result.steps.length} steps passed (native)`) : c.red(`✗ ${result.steps.filter((s2) => !s2.ok).length} of ${result.steps.length} steps failed (native)`));
222036
+ note(`Wrote ${pngFlag} (${width}x${height}, native render, final frame)`);
222037
+ process.exit(result.ok ? 0 : 1);
222038
+ }
222039
+ const timeoutMs = Number(flagStr(args, "timeout")) || 60000;
221569
222040
  note("Rendering (native)…");
221570
222041
  await runDesktopRender(js, resources, {
221571
222042
  width,
221572
222043
  height,
221573
222044
  frames,
221574
222045
  outPng: pngFlag,
221575
- logs: flagBool(args, "logs"),
221576
- timeoutMs
222046
+ logs,
222047
+ timeoutMs,
222048
+ fixedDtMs,
222049
+ camera,
222050
+ view
221577
222051
  });
221578
- note(`Wrote ${pngFlag} (${width}x${height}, native render)`);
222052
+ note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`);
222053
+ };
222054
+ var VIEW_NAMES2 = ["iso", "top", "front", "side", "back"];
222055
+ var parseVec3Flag = (name, raw) => {
222056
+ const parts = raw.split(",").map((p2) => p2.trim());
222057
+ if (parts.length !== 3 || parts.some((p2) => !/^-?\d+(\.\d+)?$/.test(p2)))
222058
+ throw new CliError(`--${name} wants "x,y,z" (got "${raw}").`);
222059
+ return parts.join(",");
222060
+ };
222061
+ var resolveInspectionCamera = (args) => {
222062
+ const cam = flagStr(args, "camera"), look = flagStr(args, "look-at"), view = flagStr(args, "view"), fov = flagStr(args, "fov");
222063
+ if (fov !== undefined && !(Number(fov) > 0 && Number(fov) < 180))
222064
+ throw new CliError(`--fov wants degrees in (0, 180), got "${fov}".`);
222065
+ const fovSuffix = fov !== undefined ? `;${Number(fov)}` : "";
222066
+ if (cam !== undefined || look !== undefined) {
222067
+ if (view !== undefined)
222068
+ throw new CliError("Use either --camera/--look-at or --view, not both.");
222069
+ if (cam === undefined || look === undefined)
222070
+ throw new CliError("--camera and --look-at go together: --camera x,y,z --look-at x,y,z");
222071
+ return { camera: `${parseVec3Flag("camera", cam)};${parseVec3Flag("look-at", look)}${fovSuffix}` };
222072
+ }
222073
+ if (view !== undefined) {
222074
+ if (!VIEW_NAMES2.includes(view))
222075
+ throw new CliError(`--view wants one of ${VIEW_NAMES2.join(", ")} (got "${view}").`);
222076
+ return { view: `${view}${fovSuffix}` };
222077
+ }
222078
+ if (fov !== undefined)
222079
+ throw new CliError("--fov goes with --camera/--look-at or --view.");
222080
+ return {};
221579
222081
  };
221580
222082
  var render = async (args) => {
221581
222083
  if (flagBool(args, "desktop"))
@@ -221597,7 +222099,7 @@ var render = async (args) => {
221597
222099
  const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined);
221598
222100
  const timeStr = flagStr(args, "time");
221599
222101
  const onConsole = flagBool(args, "logs") ? (level, text2) => logErr(`${c.dim(`[${level}]`)} ${text2}`) : undefined;
221600
- const envelopeFixtures = resolveFixtures(scenario?.fixtures, scriptFlag !== undefined ? dirname15(resolve11(scriptFlag)) : ".");
222102
+ const envelopeFixtures = resolveFixtures(scenario?.fixtures, scriptFlag !== undefined ? dirname15(resolve12(scriptFlag)) : ".");
221601
222103
  const flagFixtures = resolveFixtures(flagStr(args, "fixtures"), ".");
221602
222104
  const fixtures = envelopeFixtures || flagFixtures ? { ...envelopeFixtures, ...flagFixtures } : undefined;
221603
222105
  if (fixtures) {
@@ -221636,7 +222138,7 @@ var render = async (args) => {
221636
222138
  ${c.red(st.error ?? "failed")}`);
221637
222139
  }
221638
222140
  if (!result2.ok && result2.failureArtifacts.length > 0) {
221639
- logErr(c.dim(` failing frame: ${result2.failureArtifacts.map((p2) => resolve11(outDir, p2)).join(", ")}`));
222141
+ logErr(c.dim(` failing frame: ${result2.failureArtifacts.map((p2) => resolve12(outDir, p2)).join(", ")}`));
221640
222142
  }
221641
222143
  logErr(result2.ok ? c.green(`✓ ${result2.steps.length}/${steps.length} steps passed`) : c.red(`✗ failed at step ${result2.steps.length} of ${steps.length}`));
221642
222144
  return done((cb) => cb(), result2.ok ? 0 : 1);
@@ -221647,19 +222149,21 @@ var render = async (args) => {
221647
222149
  const pngFlag = flagStr(args, "png") ?? (flagBool(args, "png") ? "screenshot.png" : undefined);
221648
222150
  if (pngFlag !== undefined) {
221649
222151
  note("Rendering PNG…");
221650
- const { png: png2, warnings: pngWarnings } = await renderer.renderToPng(js, {
222152
+ const clip = parseClip(flagStr(args, "clip"));
222153
+ const { png: png2, width: outW, height: outH, warnings: pngWarnings } = await renderer.renderToPng(js, {
221651
222154
  width,
221652
222155
  height,
221653
222156
  scale: Number(flagStr(args, "scale")) || 2,
221654
- ...timing
222157
+ ...timing,
222158
+ clip
221655
222159
  });
221656
222160
  for (const w of [...warnings, ...pngWarnings])
221657
222161
  warnErr(w);
221658
222162
  if (isNullSink(pngFlag)) {
221659
222163
  note(`Discarded PNG (${pngFlag})`);
221660
222164
  } else {
221661
- writeFileSync19(resolve11(pngFlag), png2);
221662
- note(`Wrote ${pngFlag} (${png2.length} bytes, ${width}x${height})`);
222165
+ writeFileSync19(resolve12(pngFlag), png2);
222166
+ note(`Wrote ${pngFlag} (${png2.length} bytes, ${outW}x${outH}${clip !== undefined ? ", clipped" : ""})`);
221663
222167
  }
221664
222168
  return done((cb) => cb());
221665
222169
  }
@@ -221673,7 +222177,7 @@ var render = async (args) => {
221673
222177
  return done((cb) => cb());
221674
222178
  }
221675
222179
  if (out) {
221676
- writeFileSync19(resolve11(out), result.text);
222180
+ writeFileSync19(resolve12(out), result.text);
221677
222181
  note(`Wrote ${out} (${result.text.length} bytes)`);
221678
222182
  return done((cb) => cb());
221679
222183
  }
@@ -222034,7 +222538,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
222034
222538
  // src/commands/scene.ts
222035
222539
  init_manifest();
222036
222540
  import { existsSync as existsSync27, readFileSync as readFileSync25, readdirSync as readdirSync12, statSync as statSync8, writeFileSync as writeFileSync22 } from "node:fs";
222037
- import { basename as basename14, dirname as dirname17, join as join27, resolve as resolve12 } from "node:path";
222541
+ import { basename as basename14, dirname as dirname17, join as join27, resolve as resolve13 } from "node:path";
222038
222542
 
222039
222543
  // src/compile/sceneCompile.ts
222040
222544
  init_localFiles();
@@ -222107,7 +222611,7 @@ var getContext2 = (args) => {
222107
222611
  manifest = null;
222108
222612
  const pathArg = args._[0];
222109
222613
  if (pathArg) {
222110
- const abs = resolve12(process.cwd(), pathArg);
222614
+ const abs = resolve13(process.cwd(), pathArg);
222111
222615
  if (existsSync27(abs) && statSync8(abs).isDirectory())
222112
222616
  root = abs;
222113
222617
  else if (abs.endsWith(".scene.ts") && existsSync27(abs))
@@ -222235,8 +222739,9 @@ var status2 = async (_args) => {
222235
222739
  };
222236
222740
 
222237
222741
  // src/commands/test.ts
222238
- import { existsSync as existsSync28, readFileSync as readFileSync26, readdirSync as readdirSync13 } from "node:fs";
222239
- import { dirname as dirname18, resolve as resolve13 } from "node:path";
222742
+ import { existsSync as existsSync28, mkdirSync as mkdirSync20, readFileSync as readFileSync26, readdirSync as readdirSync13 } from "node:fs";
222743
+ import { dirname as dirname18, resolve as resolve14 } from "node:path";
222744
+ init_manifest();
222240
222745
  init_util();
222241
222746
  var deriveDesignFlows = (meta, entryScreen) => {
222242
222747
  const edges = meta.edges ?? [];
@@ -222321,17 +222826,39 @@ var readFlowFile = (abs, displayName) => {
222321
222826
  };
222322
222827
  };
222323
222828
  var test = async (args) => {
222324
- const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args);
222325
- const renderer = await requireRenderer();
222829
+ const desktop2 = flagBool(args, "desktop");
222830
+ if (desktop2 && flagBool(args, "flows"))
222831
+ throw new CliError("--flows needs the headless renderer (design flows tap by name) — drop --desktop.");
222832
+ const native = desktop2 ? await buildBundle(args, undefined, { localAssets: true }) : null;
222833
+ const headless = desktop2 ? null : await compileHeadlessBundle(args);
222834
+ const warnings = headless?.warnings ?? [];
222835
+ const useLocalAssets = headless?.useLocalAssets ?? false;
222836
+ const js = headless?.js ?? "";
222837
+ let root;
222838
+ if (headless)
222839
+ root = headless.root;
222840
+ else {
222841
+ try {
222842
+ root = findProjectRoot(process.cwd());
222843
+ } catch {
222844
+ root = process.cwd();
222845
+ }
222846
+ }
222847
+ const renderer = desktop2 ? null : await requireRenderer();
222848
+ const nativeFrames = Number(flagStr(args, "frames")) || 60;
222849
+ const fixedDtFlag = flagStr(args, "fixed-dt");
222850
+ const nativeFixedDt = fixedDtFlag !== undefined ? Number(fixedDtFlag) : 16;
222851
+ if (!Number.isFinite(nativeFixedDt) || nativeFixedDt < 0 || nativeFixedDt > 1000)
222852
+ throw new CliError(`--fixed-dt wants a number of ms (0 = wall clock, max 1000), got "${fixedDtFlag}".`);
222326
222853
  const projectViewport = desktopDefaultViewport(root);
222327
- const testsDir = resolve13(root, flagStr(args, "dir") ?? "tests");
222328
- const designMetaPath = resolve13(root, flagStr(args, "design-dir") ?? "design", "meta.json");
222854
+ const testsDir = resolve14(root, flagStr(args, "dir") ?? "tests");
222855
+ const designMetaPath = resolve14(root, flagStr(args, "design-dir") ?? "design", "meta.json");
222329
222856
  const asJson = flagBool(args, "json");
222330
222857
  const out = asJson ? logErr : log;
222331
222858
  const cases = [];
222332
222859
  if (args._.length > 0) {
222333
222860
  for (const given of args._) {
222334
- const candidates = [resolve13(given), resolve13(testsDir, given), resolve13(testsDir, `${given}.flow.json`)];
222861
+ const candidates = [resolve14(given), resolve14(testsDir, given), resolve14(testsDir, `${given}.flow.json`)];
222335
222862
  const abs = candidates.find((p2) => existsSync28(p2));
222336
222863
  if (!abs)
222337
222864
  throw new CliError(`No such test: "${given}" (looked in ${testsDir}).`);
@@ -222339,11 +222866,11 @@ var test = async (args) => {
222339
222866
  }
222340
222867
  } else if (existsSync28(testsDir)) {
222341
222868
  for (const f3 of readdirSync13(testsDir).filter((f4) => f4.endsWith(".flow.json")).sort()) {
222342
- cases.push(readFlowFile(resolve13(testsDir, f3), f3));
222869
+ cases.push(readFlowFile(resolve14(testsDir, f3), f3));
222343
222870
  }
222344
222871
  }
222345
222872
  const fixturesFlag = flagStr(args, "fixtures");
222346
- const defaultPack = resolve13(testsDir, "fixtures.json");
222873
+ const defaultPack = resolve14(testsDir, "fixtures.json");
222347
222874
  const flowFixtures = fixturesFlag !== undefined ? resolveFixtures(fixturesFlag, ".") : existsSync28(defaultPack) ? resolveFixtures("fixtures.json", testsDir) : undefined;
222348
222875
  const flowsOnly = flagBool(args, "flows");
222349
222876
  const wantFlows = !flagBool(args, "no-flows") && args._.length === 0;
@@ -222351,7 +222878,10 @@ var test = async (args) => {
222351
222878
  cases.length = 0;
222352
222879
  let skipped = [];
222353
222880
  let entryScreen = null;
222354
- if ((wantFlows || flowsOnly) && existsSync28(designMetaPath)) {
222881
+ if (desktop2 && wantFlows && existsSync28(designMetaPath)) {
222882
+ note("Design-derived flows skipped with --desktop (they tap by name; run `lecodes test` headless for them).");
222883
+ }
222884
+ if (!desktop2 && (wantFlows || flowsOnly) && existsSync28(designMetaPath)) {
222355
222885
  let meta;
222356
222886
  try {
222357
222887
  meta = JSON.parse(readFileSync26(designMetaPath, "utf8"));
@@ -222379,18 +222909,60 @@ var test = async (args) => {
222379
222909
  warnErr(w);
222380
222910
  throw new CliError(`No tests found. Write tests/*.flow.json scenarios, or draw activator edges in the design board (design/meta.json).`);
222381
222911
  }
222382
- const timeoutMs = Number(flagStr(args, "timeout")) || 20000 + cases.length * 1e4;
222912
+ const timeoutMs = Number(flagStr(args, "timeout")) || (desktop2 ? 60000 + cases.length * 60000 : 20000 + cases.length * 1e4);
222383
222913
  const killer = setTimeout(() => {
222384
222914
  warnErr(`Test run timed out after ${timeoutMs}ms (raise --timeout or lower --settle).`);
222385
222915
  process.exit(1);
222386
222916
  }, timeoutMs);
222387
222917
  killer.unref?.();
222388
222918
  const onConsole = flagBool(args, "logs") ? (level, text) => logErr(`${c.dim(`[${level}]`)} ${text}`) : undefined;
222389
- const artifactsRoot = resolve13(root, flagStr(args, "artifacts") ?? resolve13(testsDir, ".artifacts"));
222919
+ const artifactsRoot = resolve14(root, flagStr(args, "artifacts") ?? resolve14(testsDir, ".artifacts"));
222390
222920
  const results = [];
222921
+ const runNativeCase = async (kase) => {
222922
+ const caseDir = resolve14(artifactsRoot, slug3(kase.name));
222923
+ const plan = translateScenario(kase.steps, { outDir: caseDir, headroomFrames: nativeFrames });
222924
+ for (const st of plan.steps)
222925
+ if (st.screenshot)
222926
+ mkdirSync20(dirname18(st.screenshot), { recursive: true });
222927
+ mkdirSync20(caseDir, { recursive: true });
222928
+ const finalPng = resolve14(caseDir, "final.png");
222929
+ const { output } = await runDesktopRender(native.js, native.resources, {
222930
+ width: Number(flagStr(args, "width")) || kase.width || projectViewport?.width || 390,
222931
+ height: Number(flagStr(args, "height")) || kase.height || projectViewport?.height || 844,
222932
+ frames: plan.frameCap,
222933
+ outPng: finalPng,
222934
+ logs: flagBool(args, "logs"),
222935
+ timeoutMs: 60000 + plan.steps.length * 5000,
222936
+ script: plan.text,
222937
+ fixedDtMs: nativeFixedDt
222938
+ });
222939
+ const r2 = evaluateDesktopRun(plan, output);
222940
+ if (!r2.loaded)
222941
+ throw new CliError("This desktop host doesn't support scripted input (CREATOR_SCRIPT). Update it: lecodes desktop update (or point LECODES_DESKTOP_EXE at a newer build).");
222942
+ return {
222943
+ name: kase.name,
222944
+ kind: kase.kind,
222945
+ ok: r2.ok,
222946
+ steps: r2.steps.map((s2) => ({ desc: s2.desc, ok: s2.ok, error: s2.error, skipped: s2.skipped.length > 0 ? s2.skipped : undefined })),
222947
+ error: r2.ended ? undefined : "the host stopped before the script finished (frame cap / crash)",
222948
+ artifacts: r2.ok ? [] : [finalPng]
222949
+ };
222950
+ };
222391
222951
  for (const kase of cases) {
222392
222952
  const flagSettle = flagStr(args, "settle");
222393
222953
  let result;
222954
+ if (desktop2) {
222955
+ try {
222956
+ result = await runNativeCase(kase);
222957
+ } catch (e2) {
222958
+ if (e2 instanceof CliError && /doesn't support scripted input/.test(e2.message))
222959
+ throw e2;
222960
+ result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e2 instanceof Error ? e2.message : String(e2), artifacts: [] };
222961
+ }
222962
+ results.push(result);
222963
+ reportCase(result);
222964
+ continue;
222965
+ }
222394
222966
  try {
222395
222967
  const steps = renderer.parseScenarioSteps(kase.steps);
222396
222968
  const session = await renderer.createSession(js, {
@@ -222404,14 +222976,14 @@ var test = async (args) => {
222404
222976
  onConsole
222405
222977
  });
222406
222978
  try {
222407
- const io = fileScenarioIO(resolve13(artifactsRoot, slug3(kase.name)));
222979
+ const io = fileScenarioIO(resolve14(artifactsRoot, slug3(kase.name)));
222408
222980
  const outcome = await renderer.runScenario(session, steps, io);
222409
222981
  result = {
222410
222982
  name: kase.name,
222411
222983
  kind: kase.kind,
222412
222984
  ok: outcome.ok,
222413
222985
  steps: outcome.steps.map((s2) => ({ desc: s2.desc, ok: s2.ok, error: s2.error })),
222414
- artifacts: outcome.failureArtifacts.map((p2) => resolve13(artifactsRoot, slug3(kase.name), p2))
222986
+ artifacts: outcome.failureArtifacts.map((p2) => resolve14(artifactsRoot, slug3(kase.name), p2))
222415
222987
  };
222416
222988
  } finally {
222417
222989
  session.dispose();
@@ -222420,14 +222992,20 @@ var test = async (args) => {
222420
222992
  result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e2 instanceof Error ? e2.message : String(e2), artifacts: [] };
222421
222993
  }
222422
222994
  results.push(result);
222995
+ reportCase(result);
222996
+ }
222997
+ function reportCase(result) {
222998
+ const skippedCount = result.steps.reduce((n2, s2) => n2 + (s2.skipped?.length ?? 0), 0);
222423
222999
  if (result.ok) {
222424
- out(`${c.green("✓")} ${result.name}`);
223000
+ out(`${c.green("✓")} ${result.name}${skippedCount > 0 ? c.dim(` (${skippedCount} semantic check${skippedCount === 1 ? "" : "s"} skipped on --desktop)`) : ""}`);
222425
223001
  } else {
222426
223002
  out(`${c.red("✗")} ${result.name}`);
222427
223003
  if (result.error)
222428
223004
  out(` ${c.red(result.error)}`);
222429
223005
  for (const s2 of result.steps) {
222430
223006
  out(s2.ok ? c.dim(` ✓ ${s2.desc}`) : ` ${c.red(`✗ ${s2.desc} — ${s2.error}`)}`);
223007
+ for (const sk of s2.skipped ?? [])
223008
+ out(c.dim(` – skipped: ${sk}`));
222431
223009
  }
222432
223010
  if (result.artifacts.length > 0)
222433
223011
  out(c.dim(` failing frame: ${result.artifacts.join(", ")}`));
@@ -222473,11 +223051,11 @@ init_config();
222473
223051
  init_manifest();
222474
223052
  init_types4();
222475
223053
  init_util();
222476
- import { resolve as resolve14 } from "node:path";
223054
+ import { resolve as resolve15 } from "node:path";
222477
223055
  var label = (bundle) => `${Object.keys(bundle).length} file(s)`;
222478
223056
  var types4 = async (args) => {
222479
223057
  const dirArg = args._[0];
222480
- const root = dirArg ? resolve14(dirArg) : (() => {
223058
+ const root = dirArg ? resolve15(dirArg) : (() => {
222481
223059
  try {
222482
223060
  return findProjectRoot(process.cwd());
222483
223061
  } catch {
@@ -222543,16 +223121,16 @@ import { delimiter as delimiter2, dirname as dirname20, join as join29 } from "n
222543
223121
  // src/commands/install.ts
222544
223122
  init_util();
222545
223123
  import { execFileSync as execFileSync5 } from "node:child_process";
222546
- import { appendFileSync as appendFileSync3, chmodSync as chmodSync2, cpSync as cpSync4, existsSync as existsSync29, mkdirSync as mkdirSync20, readFileSync as readFileSync27, realpathSync, rmSync as rmSync7, symlinkSync } from "node:fs";
223124
+ import { appendFileSync as appendFileSync3, chmodSync as chmodSync2, cpSync as cpSync4, existsSync as existsSync29, mkdirSync as mkdirSync21, readFileSync as readFileSync27, realpathSync, rmSync as rmSync7, symlinkSync } from "node:fs";
222547
223125
  import { homedir as homedir7 } from "node:os";
222548
- import { basename as basename15, delimiter, dirname as dirname19, join as join28, resolve as resolve15, sep as sep2 } from "node:path";
223126
+ import { basename as basename15, delimiter, dirname as dirname19, join as join28, resolve as resolve16, sep as sep2 } from "node:path";
222549
223127
  var isWin = process.platform === "win32";
222550
223128
  var HOME = homedir7();
222551
223129
  var safeRealpath = (p2) => {
222552
223130
  try {
222553
223131
  return realpathSync(p2);
222554
223132
  } catch {
222555
- return resolve15(p2);
223133
+ return resolve16(p2);
222556
223134
  }
222557
223135
  };
222558
223136
  var onPath = (dir) => {
@@ -222580,7 +223158,7 @@ var install = async (args) => {
222580
223158
  return installUnix(args, bundle, companions);
222581
223159
  };
222582
223160
  var copyBundle = (bundle, target, companions) => {
222583
- mkdirSync20(target, { recursive: true });
223161
+ mkdirSync21(target, { recursive: true });
222584
223162
  if (safeRealpath(target) === safeRealpath(bundle))
222585
223163
  return;
222586
223164
  for (const f3 of companions) {
@@ -222598,8 +223176,8 @@ var permError = (e2, dir, system) => {
222598
223176
  };
222599
223177
  var installUnix = (args, bundle, companions) => {
222600
223178
  const system = flagBool(args, "system");
222601
- const libDir = resolve15(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join28(HOME, ".local", "lib", "lecodes-cli")));
222602
- const binDir = resolve15(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join28(HOME, ".local", "bin")));
223179
+ const libDir = resolve16(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join28(HOME, ".local", "lib", "lecodes-cli")));
223180
+ const binDir = resolve16(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join28(HOME, ".local", "bin")));
222603
223181
  const link2 = join28(binDir, "lecodes");
222604
223182
  try {
222605
223183
  copyBundle(bundle, libDir, companions);
@@ -222609,7 +223187,7 @@ var installUnix = (args, bundle, companions) => {
222609
223187
  throw permError(e2, libDir, system);
222610
223188
  }
222611
223189
  try {
222612
- mkdirSync20(binDir, { recursive: true });
223190
+ mkdirSync21(binDir, { recursive: true });
222613
223191
  rmSync7(link2, { force: true });
222614
223192
  symlinkSync(join28(libDir, "lecodes"), link2);
222615
223193
  } catch (e2) {
@@ -222670,7 +223248,7 @@ var finishPathUnix = (args, binDir) => {
222670
223248
  log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`);
222671
223249
  };
222672
223250
  var installWindows = (args, bundle, companions) => {
222673
- const target = resolve15(flagStr(args, "dir") ?? join28(process.env.LOCALAPPDATA ?? join28(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
223251
+ const target = resolve16(flagStr(args, "dir") ?? join28(process.env.LOCALAPPDATA ?? join28(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
222674
223252
  try {
222675
223253
  copyBundle(bundle, target, companions);
222676
223254
  } catch (e2) {
@@ -222811,8 +223389,12 @@ ${c.bold("Commands:")}
222811
223389
  --png [file] rasterize a PNG (default screenshot.png;
222812
223390
  needs @napi-rs/canvas)
222813
223391
  --script <file> drive the app through a step list (tap/type/
222814
- scroll/wait/expect/screenshot/json steps)
223392
+ scroll/wait/expect/screenshot/json steps;
223393
+ games: frames/drag/hold/key/keyDown/keyUp,
223394
+ expect.log, screenshot clip)
222815
223395
  --out-dir <dir> where --script step outputs land (default .)
223396
+ --clip <name|x,y,w,h> (--png) crop to one node's rect or a
223397
+ logical-px rect — a component/region render
222816
223398
  --fixtures <file> canned fetch responses (URL pattern →
222817
223399
  { status?, json?, text? }) — make
222818
223400
  backend-gated screens reachable headless
@@ -222830,6 +223412,22 @@ ${c.bold("Commands:")}
222830
223412
  LECODES_DESKTOP_EXE overrides)
222831
223413
  --frames <n> (--desktop) frames to run before the shot —
222832
223414
  headroom for async loads (default 60)
223415
+ --desktop --script <file> drive the scenario through the native
223416
+ host: tap/drag/hold take { x, y } coordinates
223417
+ (no names — the host has no UI tree; type/
223418
+ scroll/clip unavailable), expect.log is
223419
+ checked, text/node/screen/value are skipped;
223420
+ per-step screenshots + final --png
223421
+ --view <name> (--desktop) inspection camera auto-fitted to the
223422
+ whole 3D scene: iso | top | front | side | back
223423
+ [--fov N]; the app's camera is untouched
223424
+ --camera x,y,z --look-at x,y,z [--fov N]
223425
+ (--desktop) inspection camera at a fixed eye /
223426
+ look-at point (world units, Y up). In a
223427
+ --script run use { "view" } / { "camera" } steps
223428
+ --fixed-dt <ms> (--desktop) virtual app clock, ms per frame —
223429
+ deterministic game time (scripted runs
223430
+ default to 16; plain renders to wall clock)
222833
223431
  test [name…] Run headless flow tests: tests/*.flow.json scenarios, plus flows
222834
223432
  derived from the design graph (design/meta.json activator edges
222835
223433
  — checks the built app against the designed navigation; needs
@@ -222846,6 +223444,11 @@ ${c.bold("Commands:")}
222846
223444
  --dir <folder> tests folder (default tests/)
222847
223445
  --design-dir <dir> design folder (default design/)
222848
223446
  --artifacts <dir> failure-artifact folder override
223447
+ --desktop run the file scenarios through the NATIVE host
223448
+ (coordinates only; expect.log checked,
223449
+ text/node/screen/value skipped; design flows
223450
+ stay headless). --frames / --fixed-dt as in
223451
+ render --desktop
222849
223452
  (+ the render timing flags: --width/--height, --settle,
222850
223453
  --device, --safe-area, --wait-network, --logs, --entry)
222851
223454
  types [dir] Refresh the IDE types (.lecodes/types/ + tsconfig.json) — by
@@ -222884,7 +223487,9 @@ ${c.bold("Commands:")}
222884
223487
  app.json fullscreen; --scale <0.5..4|fit> overrides
222885
223488
  the display scale — "fit" sizes a 1080p-designed app
222886
223489
  to the monitor with the density raised to match;
222887
- --entry overrides the entrypoint)
223490
+ --entry overrides the entrypoint; asset() resources
223491
+ load from the working tree — --remote-assets or
223492
+ --public-url <url> uses the server's copies instead)
222888
223493
  update move to the newest published release
222889
223494
  (cached in ~/.lecodes/renderer)
222890
223495
  scene [path] LeCodes Scene Editor — visually edit a .scene.ts (tree, inspector,