lecodes-cli 0.8.1 → 0.9.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
@@ -2007,7 +2007,7 @@ var require_main = __commonJS((exports, module) => {
2007
2007
  // package.json
2008
2008
  var package_default = {
2009
2009
  name: "lecodes-cli",
2010
- version: "0.8.1",
2010
+ version: "0.9.0",
2011
2011
  description: "Clone, edit and push LeCodes projects from your machine.",
2012
2012
  type: "module",
2013
2013
  license: "MIT",
@@ -2037,11 +2037,11 @@ var package_default = {
2037
2037
  prepublishOnly: "bun run sync-peers && bun run build"
2038
2038
  },
2039
2039
  dependencies: {
2040
- "@letary/chisel": "^0.4.1"
2040
+ "@letary/chisel": "^0.4.2"
2041
2041
  },
2042
2042
  peerDependencies: {
2043
2043
  "lecodes-design": "^0.3.2",
2044
- "lecodes-renderer": "^0.6.2",
2044
+ "lecodes-renderer": "^0.7.0",
2045
2045
  "lecodes-3d-editor": "^0.1.0"
2046
2046
  },
2047
2047
  peerDependenciesMeta: {
@@ -2203,9 +2203,42 @@ var writeManifest = (root, manifest) => {
2203
2203
  writeFileSync(manifestPath(root), JSON.stringify(manifest, null, 2));
2204
2204
  };
2205
2205
 
2206
- // src/ignore.ts
2207
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
2206
+ // src/config.ts
2207
+ import { homedir } from "node:os";
2208
2208
  import { join as join2 } from "node:path";
2209
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
2210
+ var DEFAULT_API = "https://le.codes";
2211
+ var CONFIG_DIR = join2(homedir(), ".lecodes");
2212
+ var CONFIG_FILE = join2(CONFIG_DIR, "config.json");
2213
+ var loadConfig = () => {
2214
+ let stored = {};
2215
+ try {
2216
+ stored = JSON.parse(readFileSync2(CONFIG_FILE, "utf8"));
2217
+ } catch {}
2218
+ return {
2219
+ apiUrl: process.env.LECODES_API ?? stored.apiUrl,
2220
+ token: process.env.LECODES_TOKEN ?? stored.token
2221
+ };
2222
+ };
2223
+ var saveConfig = (config) => {
2224
+ mkdirSync2(CONFIG_DIR, { recursive: true });
2225
+ writeFileSync2(CONFIG_FILE, JSON.stringify(config, null, 2));
2226
+ };
2227
+ var normalizeApiUrl = (url) => url.trim().replace(/\/+$/, "");
2228
+ var requireToken = (config) => {
2229
+ if (!config.token)
2230
+ throw new CliError("Not logged in. Run `lecodes login` first.");
2231
+ return config.token;
2232
+ };
2233
+ var requireApiUrl = (config) => {
2234
+ if (!config.apiUrl)
2235
+ throw new CliError("No server URL configured. Run `lecodes login` first.");
2236
+ return normalizeApiUrl(config.apiUrl);
2237
+ };
2238
+
2239
+ // src/ignore.ts
2240
+ import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
2241
+ import { join as join3 } from "node:path";
2209
2242
  var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store"];
2210
2243
  var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
2211
2244
  # Only untracked files are affected — assets already in the project stay tracked
@@ -2286,19 +2319,19 @@ var buildGlobMatcher = (patterns) => {
2286
2319
  };
2287
2320
  var loadIgnore = (root) => {
2288
2321
  const lines = [...BUILTIN_PATTERNS];
2289
- const file = join2(root, ".lecodesignore");
2322
+ const file = join3(root, ".lecodesignore");
2290
2323
  if (existsSync2(file)) {
2291
2324
  try {
2292
- lines.push(...readFileSync2(file, "utf8").split(/\r?\n/));
2325
+ lines.push(...readFileSync3(file, "utf8").split(/\r?\n/));
2293
2326
  } catch {}
2294
2327
  }
2295
2328
  const rules = lines.map(compile).filter((r) => r !== null);
2296
2329
  return buildMatcher(rules);
2297
2330
  };
2298
2331
  var writeDefaultIgnore = (root) => {
2299
- const file = join2(root, ".lecodesignore");
2332
+ const file = join3(root, ".lecodesignore");
2300
2333
  if (!existsSync2(file))
2301
- writeFileSync2(file, DEFAULT_IGNORE_FILE);
2334
+ writeFileSync3(file, DEFAULT_IGNORE_FILE);
2302
2335
  };
2303
2336
 
2304
2337
  // src/commands/compile.ts
@@ -2370,39 +2403,6 @@ var downloadBlob = async (apiUrl, src) => {
2370
2403
  return new Uint8Array(await resp.arrayBuffer());
2371
2404
  };
2372
2405
 
2373
- // src/config.ts
2374
- import { homedir } from "node:os";
2375
- import { join as join3 } from "node:path";
2376
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
2377
- var DEFAULT_API = "https://le.codes";
2378
- var CONFIG_DIR = join3(homedir(), ".lecodes");
2379
- var CONFIG_FILE = join3(CONFIG_DIR, "config.json");
2380
- var loadConfig = () => {
2381
- let stored = {};
2382
- try {
2383
- stored = JSON.parse(readFileSync3(CONFIG_FILE, "utf8"));
2384
- } catch {}
2385
- return {
2386
- apiUrl: process.env.LECODES_API ?? stored.apiUrl,
2387
- token: process.env.LECODES_TOKEN ?? stored.token
2388
- };
2389
- };
2390
- var saveConfig = (config) => {
2391
- mkdirSync2(CONFIG_DIR, { recursive: true });
2392
- writeFileSync3(CONFIG_FILE, JSON.stringify(config, null, 2));
2393
- };
2394
- var normalizeApiUrl = (url) => url.trim().replace(/\/+$/, "");
2395
- var requireToken = (config) => {
2396
- if (!config.token)
2397
- throw new CliError("Not logged in. Run `lecodes login` first.");
2398
- return config.token;
2399
- };
2400
- var requireApiUrl = (config) => {
2401
- if (!config.apiUrl)
2402
- throw new CliError("No server URL configured. Run `lecodes login` first.");
2403
- return normalizeApiUrl(config.apiUrl);
2404
- };
2405
-
2406
2406
  // src/compile/collect.ts
2407
2407
  import { readFileSync as readFileSync5 } from "node:fs";
2408
2408
  import { basename } from "node:path";
@@ -3349,8 +3349,12 @@ class ViewController: LeCodesViewController {
3349
3349
 
3350
3350
  registerBundledPlugins(in: engine)
3351
3351
 
3352
- guard let url = Bundle.main.url(forResource: "app", withExtension: "js"),
3353
- let code = try? String(contentsOf: url, encoding: .utf8) else {
3352
+ // OTA (native, no JS involved): start the check FIRST — even a bundle that crashes
3353
+ // on boot can never block its own fix — then run the newest local bundle (a
3354
+ // downloaded update, or the embedded app.js). New code applies on the next launch.
3355
+ // Configured via app.json "update" (→ Info.plist LeCodesUpdateURL); offline: no-op.
3356
+ LeCodesUpdater.checkForUpdate()
3357
+ guard let code = LeCodesUpdater.activeBundle() else {
3354
3358
  print("LeCodes: bundled app.js is missing — run \`lecodes app sync\`")
3355
3359
  return
3356
3360
  }
@@ -3358,13 +3362,18 @@ class ViewController: LeCodesViewController {
3358
3362
  }
3359
3363
  }
3360
3364
  `;
3361
- var infoPlistTemplate = (pluginKeys) => {
3365
+ var infoPlistTemplate = (pluginKeys, updateUrl) => {
3366
+ const xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3362
3367
  const keys = Object.keys(pluginKeys).sort();
3363
3368
  const pluginBlock = keys.length === 0 ? "" : `
3364
3369
  <!-- lecodes:plugins — merged from installed plugin manifests by \`lecodes app sync\` -->
3365
3370
  ${keys.map((k) => ` <key>${k}</key>
3366
3371
  <string>${pluginKeys[k]}</string>`).join(`
3367
3372
  `)}`;
3373
+ const updateBlock = !updateUrl ? "" : `
3374
+ <!-- lecodes:update — native OTA source (app.json "update"); LeCodesUpdater checks it at launch -->
3375
+ <key>LeCodesUpdateURL</key>
3376
+ <string>${xml(updateUrl)}</string>`;
3368
3377
  return `<?xml version="1.0" encoding="UTF-8"?>
3369
3378
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3370
3379
  <plist version="1.0">
@@ -3385,7 +3394,7 @@ ${keys.map((k) => ` <key>${k}</key>
3385
3394
  </dict>
3386
3395
  </array>
3387
3396
  </dict>
3388
- </dict>${pluginBlock}
3397
+ </dict>${pluginBlock}${updateBlock}
3389
3398
  </dict>
3390
3399
  </plist>
3391
3400
  `;
@@ -3579,6 +3588,16 @@ Sync links the smallest SDK product that covers what the compiled bundle actuall
3579
3588
  project switches it automatically on the next sync; set \`ios.variant\` in \`../app.json\`
3580
3589
  to pin one instead.
3581
3590
 
3591
+ ## Over-the-air updates
3592
+
3593
+ With \`"update": true\` in \`../app.json\` (server-backed projects; or
3594
+ \`"update": {"url": "https://…/app.js"}\` for any static hosting), the app checks that URL
3595
+ at launch — natively, before any JS runs, so a broken bundle can never block its own fix.
3596
+ Unchanged code costs a ~0-byte 304; new code downloads in the background and runs on the
3597
+ NEXT launch. Offline it's a silent no-op — the app always launches from a local bundle.
3598
+ An App Store update always beats a cached OTA bundle. JS only: adding/removing native
3599
+ plugins still needs a store release.
3600
+
3582
3601
  ## Config
3583
3602
 
3584
3603
  Identity lives in \`../app.json\` (name, version, plugins, ios.bundleId / teamId / variant /
@@ -3587,7 +3606,7 @@ buildNumber) — edit it there, then re-run \`lecodes app sync\`. Plugins are ma
3587
3606
  `;
3588
3607
 
3589
3608
  // src/commands/app.ts
3590
- var SDK_PACKAGE_VERSION = "1.1.0";
3609
+ var SDK_PACKAGE_VERSION = "1.2.0";
3591
3610
  var SDK_VARIANTS = ["core", "2d", "3d", "full"];
3592
3611
  var FIRST_PARTY_PLUGINS = {
3593
3612
  camera: "letary/lecodes-plugins",
@@ -3809,6 +3828,25 @@ var ensureSdkResourcesBundle = async () => {
3809
3828
  throw new CliError(`No LeCodesSDKResources.bundle inside the release asset ${url}`);
3810
3829
  return bundle;
3811
3830
  };
3831
+ var resolveUpdateUrl = (update, root) => {
3832
+ if (!update)
3833
+ return;
3834
+ let url = typeof update === "string" ? update : typeof update === "object" ? update.url : undefined;
3835
+ if (!url) {
3836
+ let manifest = null;
3837
+ try {
3838
+ manifest = readManifest(root);
3839
+ } catch {}
3840
+ if (!manifest) {
3841
+ warn(`app.json "update": true needs a server-backed project — use "update": {"url": "https://…/app.js"} for custom hosting. OTA left unconfigured.`);
3842
+ return;
3843
+ }
3844
+ url = `${requireApiUrl(loadConfig())}/code/${manifest.uuid}.js`;
3845
+ }
3846
+ if (!url.startsWith("https://"))
3847
+ warn(`update URL is not https — iOS blocks it on device (ATS): ${url}`);
3848
+ return url;
3849
+ };
3812
3850
  var appSync = async (_args) => {
3813
3851
  const root = projectRoot();
3814
3852
  const config = readJson(join11(root, "app.json"));
@@ -3916,7 +3954,10 @@ var appSync = async (_args) => {
3916
3954
  const mergedKeys = {};
3917
3955
  for (const [key, values] of Object.entries(infoValues))
3918
3956
  mergedKeys[key] = [...new Set(values)].join(" ");
3919
- writeFileSync7(join11(iosDir, "App", "Info.plist"), infoPlistTemplate(mergedKeys));
3957
+ const updateUrl = resolveUpdateUrl(config.update, root);
3958
+ writeFileSync7(join11(iosDir, "App", "Info.plist"), infoPlistTemplate(mergedKeys, updateUrl));
3959
+ if (updateUrl)
3960
+ log(`${c.green("update")} OTA source ${updateUrl} — checked natively at launch, new code applies next launch`);
3920
3961
  if (pluginSpecs.length > 0 || existsSync8(lockPath))
3921
3962
  writeJson(lockPath, { plugins: newLock });
3922
3963
  const pbxPath = join11(iosDir, "App.xcodeproj", "project.pbxproj");
@@ -4046,6 +4087,10 @@ var appDoctor = async (_args) => {
4046
4087
  const wanted = Object.keys(config.plugins ?? {});
4047
4088
  const inSync = wanted.every((id) => lock[id]) && Object.keys(lock).every((id) => wanted.includes(id));
4048
4089
  check(wanted.length === 0 || inSync, "Plugins synced", "run `lecodes app sync`");
4090
+ if (config.update) {
4091
+ const plist = readFileSync10(join11(root, "ios", "App", "Info.plist"), "utf8");
4092
+ check(plist.includes("<key>LeCodesUpdateURL</key>"), "OTA update URL in Info.plist", "run `lecodes app sync`", false);
4093
+ }
4049
4094
  const asc = readJson(join11(homedir2(), ".lecodes", "asc.json"));
4050
4095
  check(!!asc?.keyPath, "App Store Connect API key configured", "pass --key-path/--key-id/--issuer-id to `lecodes app upload` once to store it", false);
4051
4096
  log("");
@@ -4821,15 +4866,8 @@ var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser
4821
4866
  }
4822
4867
  };
4823
4868
 
4824
- // src/commands/designTemplates.ts
4825
- var META_JSON = `{
4826
- "screens": {
4827
- "home": { "pos": [0, 0], "desc": "Example screen — replace me", "role": "list" }
4828
- },
4829
- "edges": []
4830
- }
4831
- `;
4832
- var SPEC_MD = `# Concept
4869
+ // ../lecodes-design/src/core/concept.ts
4870
+ var SPEC_TEMPLATE = `# Concept
4833
4871
 
4834
4872
  The narrative half of this design. \`meta.json\` + the canvas hold the *visual/flow* half (screens,
4835
4873
  positions, edges); this file holds what never renders as a screen — the data model, the actors, and
@@ -4863,6 +4901,16 @@ which is the screen's *kind* — list, detail, form…)
4863
4901
  Behaviour that isn't visible in a single screen — validation, permissions, notifications, edge
4864
4902
  cases — and the decisions you've made and why, so they aren't re-litigated later.
4865
4903
  `;
4904
+
4905
+ // ../lecodes-design/src/templates.ts
4906
+ var SPEC_MD = SPEC_TEMPLATE;
4907
+ var META_JSON = `{
4908
+ "screens": {
4909
+ "home": { "pos": [0, 0], "desc": "Example screen — replace me", "role": "list" }
4910
+ },
4911
+ "edges": []
4912
+ }
4913
+ `;
4866
4914
  var TOKENS_TS = `// Design tokens shared by every screen. Colors are hex strings (the engine parses hex only).
4867
4915
  // App font: one theme() call here sets the default text font everywhere. font("id") installs a
4868
4916
  // registry family (vendored at compile time) and returns its name — no loading code needed.
@@ -5025,6 +5073,26 @@ positions or descriptions.
5025
5073
  \`lecodes design check\` lists screens still untagged (never blocking). *(A screen's **states** are
5026
5074
  a separate thing — declared in the source, not meta; see "Screen states" below.)*
5027
5075
 
5076
+ ## The design is testable — names carry into the app
5077
+
5078
+ Every edge with an \`activator\` doubles as a **flow test** for the real app: \`lecodes test\` (run
5079
+ at the project root once the app exists) walks the graph from the start screen, taps each edge's
5080
+ activator, and expects to land on a screen named like the edge's target. The design stays a living
5081
+ contract instead of a discarded sketch — but only if the app build keeps the wiring:
5082
+
5083
+ - **Keep the design's \`name\`s** when translating a screen to app code — every element an edge's
5084
+ \`activator\` references keeps its \`name\` (\`.style({ name: "submit" })\`).
5085
+ - **Name each app screen's root with its design screen id** — \`UIScreen([ … ]).style({ name:
5086
+ "login" })\` — that's how the runner knows where a tap landed.
5087
+ - Edges with no activator (backend/system-driven) and \`@state\` edges are reported as *not
5088
+ machine-verifiable* — cover the important ones with hand-written \`tests/*.flow.json\` scenarios
5089
+ (same step schema as \`lecodes render --script\`; tab-bar navigation needs these too, since the
5090
+ bar draws no edges).
5091
+
5092
+ Names also appear in every headless render (JSON snapshots, the board's live elements), so they
5093
+ double as stable selectors for any scripted interaction — naming interactive elements is never
5094
+ wasted work.
5095
+
5028
5096
  ## The concept — spec.md
5029
5097
 
5030
5098
  \`meta.json\` + the canvas are the *visual/flow* half of the design; \`spec.md\` is the *narrative*
@@ -5246,7 +5314,6 @@ var README_HINT = `# design/
5246
5314
  LeCodes Design canvas — run \`lecodes design\` here and open the printed URL.
5247
5315
  See CLAUDE.md for the conventions (it's written for AI assistants, but it's the spec).
5248
5316
  `;
5249
-
5250
5317
  // src/commands/design.ts
5251
5318
  var DEFAULT_DIR = "design";
5252
5319
  var DEFAULT_PORT = 4477;
@@ -6519,50 +6586,47 @@ var login = async (args) => {
6519
6586
  };
6520
6587
 
6521
6588
  // src/commands/render.ts
6522
- import { writeFileSync as writeFileSync14 } from "node:fs";
6523
- import { resolve as resolve6 } from "node:path";
6589
+ import { mkdirSync as mkdirSync13, readFileSync as readFileSync17, writeFileSync as writeFileSync14 } from "node:fs";
6590
+ import { dirname as dirname11, resolve as resolve6 } from "node:path";
6591
+
6592
+ // src/compile/headlessBundle.ts
6524
6593
  var LOCAL_ASSET_SCHEME2 = "lecodesfile://";
6525
- var DEVICE_SAFE_AREA = {
6526
- none: { insets: [0, 0, 0, 0], bars: [] },
6527
- iphone: { insets: [59, 0, 34, 0], bars: [] },
6528
- android: { insets: [24, 0, 48, 0], bars: ["top", "bottom"] }
6529
- };
6530
- var resolveSafeArea = (args) => {
6531
- const explicit = flagStr(args, "safe-area");
6532
- if (explicit) {
6533
- const n = explicit.split(",").map((s) => Number(s.trim()));
6534
- if (n.length === 4 && n.every((v) => Number.isFinite(v)))
6535
- return { safeArea: n, safeAreaBars: [] };
6536
- throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
6537
- }
6538
- const device = flagStr(args, "device");
6539
- if (device) {
6540
- const preset = DEVICE_SAFE_AREA[device];
6541
- if (!preset)
6542
- throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
6543
- return { safeArea: preset.insets, safeAreaBars: preset.bars };
6594
+ var compileHeadlessBundle = async (args) => {
6595
+ let root;
6596
+ let manifest;
6597
+ try {
6598
+ root = findProjectRoot(process.cwd());
6599
+ manifest = readManifest(root);
6600
+ } catch {
6601
+ root = process.cwd();
6602
+ manifest = null;
6544
6603
  }
6545
- return;
6546
- };
6547
- var render = async (args) => {
6548
- const timeoutMs = Number(flagStr(args, "timeout")) || 15000;
6549
- const killer = setTimeout(() => {
6550
- warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`);
6551
- process.exit(1);
6552
- }, timeoutMs);
6553
- killer.unref?.();
6554
- const root = findProjectRoot(process.cwd());
6555
- const manifest = readManifest(root);
6556
- const config = loadConfig();
6557
- const apiUrl = requireApiUrl(config);
6558
- const token = requireToken(config);
6559
- note("Resolving project assets…");
6560
- const project = await getProject(apiUrl, token, manifest.uuid);
6561
- const { entries, warnings: collectWarnings } = collectEntries(root, manifest, project);
6562
6604
  const publicUrlFlag = flagStr(args, "public-url");
6563
- const useLocalAssets = !publicUrlFlag && !flagBool(args, "remote-assets");
6605
+ const useLocalAssets = manifest ? !publicUrlFlag && !flagBool(args, "remote-assets") : true;
6606
+ let entries;
6607
+ let warnings;
6608
+ let name;
6609
+ let publicUrl;
6610
+ if (manifest) {
6611
+ const config = loadConfig();
6612
+ const apiUrl = requireApiUrl(config);
6613
+ const token = requireToken(config);
6614
+ note("Resolving project assets…");
6615
+ const project = await getProject(apiUrl, token, manifest.uuid);
6616
+ ({ entries, warnings } = collectEntries(root, manifest, project));
6617
+ name = manifest.name;
6618
+ publicUrl = useLocalAssets ? LOCAL_ASSET_SCHEME2 : publicUrlFlag ?? apiUrl;
6619
+ if (useLocalAssets)
6620
+ warnings = warnings.filter((w) => !w.includes("No server URL"));
6621
+ } else {
6622
+ ({ entries, warnings } = collectLocalEntries(root, null, ""));
6623
+ name = root.split(/[\\/]/).pop() ?? "project";
6624
+ publicUrl = LOCAL_ASSET_SCHEME2;
6625
+ if (flagBool(args, "remote-assets") || publicUrlFlag) {
6626
+ warnings.push("This is a local project (no server) — assets always load from the working tree.");
6627
+ }
6628
+ }
6564
6629
  const compileEntries = useLocalAssets ? entries.map((e) => e.type === "resource" && e.absPath ? { ...e, fileSrc: encodeURIComponent(e.absPath) } : e) : entries;
6565
- const warnings = useLocalAssets ? collectWarnings.filter((w) => !w.includes("No server URL")) : collectWarnings;
6566
6630
  await applyProjectFonts(compileEntries, useLocalAssets ? {
6567
6631
  mode: "vendor",
6568
6632
  root,
@@ -6573,8 +6637,8 @@ var render = async (args) => {
6573
6637
  note("Compiling…");
6574
6638
  const js = await compileProject2({
6575
6639
  entries: compileEntries,
6576
- name: manifest.name,
6577
- publicUrl: useLocalAssets ? LOCAL_ASSET_SCHEME2 : publicUrlFlag ?? apiUrl,
6640
+ name,
6641
+ publicUrl,
6578
6642
  entryOverride: flagStr(args, "entry"),
6579
6643
  header: false,
6580
6644
  format: "iife"
@@ -6585,15 +6649,75 @@ var render = async (args) => {
6585
6649
  if (js.includes("_creator2d") && !useLocalAssets && !flagBool(args, "wait-network")) {
6586
6650
  warnings.push("2D scene detected: sprite textures load over the network — add --wait-network to include them (else sprites render as flat placeholders).");
6587
6651
  }
6588
- let renderer;
6652
+ return { js, root, warnings, useLocalAssets };
6653
+ };
6654
+
6655
+ // src/commands/render.ts
6656
+ var DEVICE_SAFE_AREA = {
6657
+ none: { insets: [0, 0, 0, 0], bars: [] },
6658
+ iphone: { insets: [59, 0, 34, 0], bars: [] },
6659
+ android: { insets: [24, 0, 48, 0], bars: ["top", "bottom"] }
6660
+ };
6661
+ var resolveSafeAreaChoice = (explicit, device) => {
6662
+ if (explicit) {
6663
+ const n = explicit.split(",").map((s) => Number(s.trim()));
6664
+ if (n.length === 4 && n.every((v) => Number.isFinite(v)))
6665
+ return { safeArea: n, safeAreaBars: [] };
6666
+ throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
6667
+ }
6668
+ if (device) {
6669
+ const preset = DEVICE_SAFE_AREA[device];
6670
+ if (!preset)
6671
+ throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
6672
+ return { safeArea: preset.insets, safeAreaBars: preset.bars };
6673
+ }
6674
+ return;
6675
+ };
6676
+ var requireRenderer = async () => {
6589
6677
  try {
6590
- renderer = await import("lecodes-renderer/headless");
6678
+ return await import("lecodes-renderer/headless");
6591
6679
  } catch {
6592
6680
  throw new CliError("The renderer isn't installed. Run: npm i -D lecodes-renderer");
6593
6681
  }
6594
- const width = Number(flagStr(args, "width")) || 390;
6595
- const height = Number(flagStr(args, "height")) || 844;
6596
- const settleStr = flagStr(args, "settle");
6682
+ };
6683
+ var fileScenarioIO = (dir) => ({
6684
+ write: (relPath, data) => {
6685
+ const abs = resolve6(dir, relPath);
6686
+ mkdirSync13(dirname11(abs), { recursive: true });
6687
+ writeFileSync14(abs, data);
6688
+ }
6689
+ });
6690
+ var readScenarioFile = (path) => {
6691
+ let raw;
6692
+ try {
6693
+ raw = JSON.parse(readFileSync17(resolve6(path), "utf8"));
6694
+ } catch (e) {
6695
+ throw new CliError(`Can't read scenario "${path}": ${e instanceof Error ? e.message : String(e)}`);
6696
+ }
6697
+ if (Array.isArray(raw))
6698
+ return { steps: raw };
6699
+ if (typeof raw !== "object" || raw === null)
6700
+ throw new CliError(`Scenario "${path}" must be a JSON object with "steps" (or a bare step array).`);
6701
+ const file = raw;
6702
+ if (file.steps === undefined)
6703
+ throw new CliError(`Scenario "${path}" has no "steps".`);
6704
+ return file;
6705
+ };
6706
+ var render = async (args) => {
6707
+ const scriptFlag = flagStr(args, "script");
6708
+ const scenario = scriptFlag !== undefined ? readScenarioFile(scriptFlag) : null;
6709
+ const stepCount = scenario && Array.isArray(scenario.steps) ? scenario.steps.length : 0;
6710
+ const timeoutMs = Number(flagStr(args, "timeout")) || 15000 + stepCount * 3000;
6711
+ const killer = setTimeout(() => {
6712
+ warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`);
6713
+ process.exit(1);
6714
+ }, timeoutMs);
6715
+ killer.unref?.();
6716
+ const { js, warnings, useLocalAssets } = await compileHeadlessBundle(args);
6717
+ const renderer = await requireRenderer();
6718
+ const width = Number(flagStr(args, "width")) || scenario?.width || 390;
6719
+ const height = Number(flagStr(args, "height")) || scenario?.height || 844;
6720
+ const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined);
6597
6721
  const timeStr = flagStr(args, "time");
6598
6722
  const onConsole = flagBool(args, "logs") ? (level, text2) => logErr(`${c.dim(`[${level}]`)} ${text2}`) : undefined;
6599
6723
  const timing = {
@@ -6602,13 +6726,36 @@ var render = async (args) => {
6602
6726
  localAssets: useLocalAssets,
6603
6727
  timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
6604
6728
  afterAnimations: !flagBool(args, "no-after-animations"),
6605
- ...resolveSafeArea(args),
6729
+ ...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? scenario?.safeArea, flagStr(args, "device") ?? scenario?.device),
6606
6730
  onConsole
6607
6731
  };
6608
- const done = (flush) => flush(() => {
6732
+ const done = (flush, code = 0) => flush(() => {
6609
6733
  clearTimeout(killer);
6610
- process.exit(0);
6734
+ process.exit(code);
6611
6735
  });
6736
+ if (scenario) {
6737
+ const steps = renderer.parseScenarioSteps(scenario.steps);
6738
+ const outDir = flagStr(args, "out-dir") ?? ".";
6739
+ const needsRaster = steps.some((s) => s.screenshot !== undefined);
6740
+ note(`Running ${steps.length} step${steps.length === 1 ? "" : "s"}…`);
6741
+ const session = await renderer.createSession(js, { width, height, ...timing, raster: needsRaster ? true : "auto" });
6742
+ try {
6743
+ const result2 = await renderer.runScenario(session, steps, fileScenarioIO(outDir));
6744
+ for (const w of [...warnings, ...session.warnings])
6745
+ warnErr(w);
6746
+ for (const st of result2.steps) {
6747
+ logErr(st.ok ? `${c.green("✓")} ${st.desc}` : `${c.red("✗")} ${st.desc}
6748
+ ${c.red(st.error ?? "failed")}`);
6749
+ }
6750
+ if (!result2.ok && result2.failureArtifacts.length > 0) {
6751
+ logErr(c.dim(` failing frame: ${result2.failureArtifacts.map((p) => resolve6(outDir, p)).join(", ")}`));
6752
+ }
6753
+ logErr(result2.ok ? c.green(`✓ ${result2.steps.length}/${steps.length} steps passed`) : c.red(`✗ failed at step ${result2.steps.length} of ${steps.length}`));
6754
+ return done((cb) => cb(), result2.ok ? 0 : 1);
6755
+ } finally {
6756
+ session.dispose();
6757
+ }
6758
+ }
6612
6759
  const pngFlag = flagStr(args, "png") ?? (flagBool(args, "png") ? "screenshot.png" : undefined);
6613
6760
  if (pngFlag !== undefined) {
6614
6761
  note("Rendering PNG…");
@@ -6649,8 +6796,8 @@ var render = async (args) => {
6649
6796
  };
6650
6797
 
6651
6798
  // src/commands/pull.ts
6652
- import { existsSync as existsSync17, mkdirSync as mkdirSync13, rmSync as rmSync2, writeFileSync as writeFileSync15 } from "node:fs";
6653
- import { dirname as dirname11, join as join19 } from "node:path";
6799
+ import { existsSync as existsSync17, mkdirSync as mkdirSync14, rmSync as rmSync2, writeFileSync as writeFileSync15 } from "node:fs";
6800
+ import { dirname as dirname12, join as join19 } from "node:path";
6654
6801
  var pull = async (args) => {
6655
6802
  const root = findProjectRoot(process.cwd());
6656
6803
  const manifest = readManifest(root);
@@ -6710,7 +6857,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
6710
6857
  for (const asset of targets) {
6711
6858
  const key = pathById.get(asset.id);
6712
6859
  const abs = join19(root, ...key.replace(/^\//, "").split("/"));
6713
- mkdirSync13(dirname11(abs), { recursive: true });
6860
+ mkdirSync14(dirname12(abs), { recursive: true });
6714
6861
  let sha;
6715
6862
  if (asset.type === "file") {
6716
6863
  if (!asset.file?.src)
@@ -6733,7 +6880,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
6733
6880
 
6734
6881
  // src/commands/push.ts
6735
6882
  import { basename as basename10 } from "node:path";
6736
- import { readFileSync as readFileSync17 } from "node:fs";
6883
+ import { readFileSync as readFileSync18 } from "node:fs";
6737
6884
  var push = async (args) => {
6738
6885
  const root = findProjectRoot(process.cwd());
6739
6886
  const manifest = readManifest(root);
@@ -6792,9 +6939,9 @@ var push = async (args) => {
6792
6939
  if (source)
6793
6940
  entry.assetId = source.id;
6794
6941
  if (isTextKind(kind)) {
6795
- entry.text = readFileSync17(file.absPath, "utf8");
6942
+ entry.text = readFileSync18(file.absPath, "utf8");
6796
6943
  } else if (!source || source.sha !== file.sha) {
6797
- const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync17(file.absPath));
6944
+ const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync18(file.absPath));
6798
6945
  entry.blobFileId = blob.id;
6799
6946
  uploaded++;
6800
6947
  }
@@ -6847,11 +6994,11 @@ var reportPublish = (res) => {
6847
6994
  };
6848
6995
 
6849
6996
  // src/commands/scene.ts
6850
- import { existsSync as existsSync19, readFileSync as readFileSync19, readdirSync as readdirSync8, statSync as statSync4, writeFileSync as writeFileSync16 } from "node:fs";
6851
- import { basename as basename11, dirname as dirname12, join as join21, resolve as resolve7 } from "node:path";
6997
+ import { existsSync as existsSync19, readFileSync as readFileSync20, readdirSync as readdirSync8, statSync as statSync4, writeFileSync as writeFileSync16 } from "node:fs";
6998
+ import { basename as basename11, dirname as dirname13, join as join21, resolve as resolve7 } from "node:path";
6852
6999
 
6853
7000
  // src/compile/sceneCompile.ts
6854
- import { existsSync as existsSync18, readFileSync as readFileSync18 } from "node:fs";
7001
+ import { existsSync as existsSync18, readFileSync as readFileSync19 } from "node:fs";
6855
7002
  import { join as join20 } from "node:path";
6856
7003
  var TEXT_EXT = /\.(ts|js|json|svg|txt|md)$/i;
6857
7004
  var rel = (key) => key.replace(/^\//, "");
@@ -6862,7 +7009,7 @@ var resolveHarness = (loadSceneHarness2) => {
6862
7009
  } catch {
6863
7010
  const vendored = join20(distRoot, "runtime", "scene-harness.json");
6864
7011
  if (existsSync18(vendored))
6865
- return JSON.parse(readFileSync18(vendored, "utf8"));
7012
+ return JSON.parse(readFileSync19(vendored, "utf8"));
6866
7013
  throw new CliError("scene-editor harness sources not found — reinstall the CLI or set LECODES_SCENE_HARNESS_DIR.");
6867
7014
  }
6868
7015
  };
@@ -6874,7 +7021,7 @@ var compileSceneEditor = async (opts) => {
6874
7021
  for (const file of scan.files) {
6875
7022
  const path = "./" + rel(file.path);
6876
7023
  if (TEXT_EXT.test(path)) {
6877
- entries.push({ path, type: "text", text: readFileSync18(file.absPath, "utf8") });
7024
+ entries.push({ path, type: "text", text: readFileSync19(file.absPath, "utf8") });
6878
7025
  } else {
6879
7026
  entries.push({ path, type: "resource", fileSrc: "/project/" + rel(file.path) });
6880
7027
  }
@@ -6918,7 +7065,7 @@ var getContext2 = (args) => {
6918
7065
  if (existsSync19(abs) && statSync4(abs).isDirectory())
6919
7066
  root = abs;
6920
7067
  else if (abs.endsWith(".scene.ts") && existsSync19(abs))
6921
- root = dirname12(abs);
7068
+ root = dirname13(abs);
6922
7069
  else
6923
7070
  throw new CliError(`No such folder or .scene.ts: ${pathArg}`);
6924
7071
  }
@@ -6931,7 +7078,7 @@ var writeMcpConfig2 = (root, port) => {
6931
7078
  let config = {};
6932
7079
  if (existsSync19(path)) {
6933
7080
  try {
6934
- config = JSON.parse(readFileSync19(path, "utf8"));
7081
+ config = JSON.parse(readFileSync20(path, "utf8"));
6935
7082
  } catch {
6936
7083
  warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-scene server by hand.");
6937
7084
  return;
@@ -7023,23 +7170,239 @@ var status = async (_args) => {
7023
7170
  info(c.dim(` ${scan.ignored.length} ignored (.lecodesignore)`));
7024
7171
  };
7025
7172
 
7173
+ // src/commands/test.ts
7174
+ import { existsSync as existsSync20, readFileSync as readFileSync21, readdirSync as readdirSync9 } from "node:fs";
7175
+ import { resolve as resolve8 } from "node:path";
7176
+ var deriveDesignFlows = (meta, entryScreen) => {
7177
+ const edges = meta.edges ?? [];
7178
+ const isState = (id) => id.includes("@");
7179
+ const verifiable = edges.filter((e) => e.activator && !isState(e.from) && !isState(e.to));
7180
+ const prev = new Map;
7181
+ const visited = new Set([entryScreen]);
7182
+ const queue = [entryScreen];
7183
+ while (queue.length > 0) {
7184
+ const at = queue.shift();
7185
+ for (const e of verifiable) {
7186
+ if (e.from !== at || visited.has(e.to))
7187
+ continue;
7188
+ visited.add(e.to);
7189
+ prev.set(e.to, e);
7190
+ queue.push(e.to);
7191
+ }
7192
+ }
7193
+ const pathTo = (screen) => {
7194
+ if (screen === entryScreen)
7195
+ return [];
7196
+ if (!visited.has(screen))
7197
+ return null;
7198
+ const hops = [];
7199
+ for (let at = screen;at !== entryScreen; at = hops[0].from)
7200
+ hops.unshift(prev.get(at));
7201
+ return hops;
7202
+ };
7203
+ const flows = [];
7204
+ const skipped = [];
7205
+ for (const e of edges) {
7206
+ const name = `${e.from} —${e.activator ?? "?"}→ ${e.to}`;
7207
+ if (!e.activator) {
7208
+ skipped.push({ name, reason: "no activator (backend/gesture-driven) — cover it with a tests/*.flow.json" });
7209
+ continue;
7210
+ }
7211
+ if (isState(e.from) || isState(e.to)) {
7212
+ skipped.push({ name, reason: "screen-state edge (@state) — states are runtime conditions, not screens" });
7213
+ continue;
7214
+ }
7215
+ const path = pathTo(e.from);
7216
+ if (path === null) {
7217
+ skipped.push({ name, reason: `"${e.from}" has no activator path from "${entryScreen}" (tab-bar navigation?) — cover it with a tests/*.flow.json` });
7218
+ continue;
7219
+ }
7220
+ flows.push({
7221
+ name,
7222
+ kind: "flow",
7223
+ steps: [
7224
+ ...path.map((hop) => ({ tap: hop.activator, expect: { screen: hop.to } })),
7225
+ { tap: e.activator, expect: { screen: e.to } }
7226
+ ]
7227
+ });
7228
+ }
7229
+ return { flows, skipped };
7230
+ };
7231
+ var slug3 = (s) => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "case";
7232
+ var readFlowFile = (abs, displayName) => {
7233
+ let raw;
7234
+ try {
7235
+ raw = JSON.parse(readFileSync21(abs, "utf8"));
7236
+ } catch (e) {
7237
+ throw new CliError(`Can't read "${displayName}": ${e instanceof Error ? e.message : String(e)}`);
7238
+ }
7239
+ if (Array.isArray(raw))
7240
+ return { name: displayName, kind: "file", steps: raw };
7241
+ if (typeof raw !== "object" || raw === null || raw.steps === undefined) {
7242
+ throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`);
7243
+ }
7244
+ const file = raw;
7245
+ return { name: file.name ?? displayName, kind: "file", steps: file.steps, width: file.width, height: file.height, settle: file.settle, device: file.device, safeArea: file.safeArea };
7246
+ };
7247
+ var test = async (args) => {
7248
+ const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args);
7249
+ const renderer = await requireRenderer();
7250
+ const testsDir = resolve8(root, flagStr(args, "dir") ?? "tests");
7251
+ const designMetaPath = resolve8(root, flagStr(args, "design-dir") ?? "design", "meta.json");
7252
+ const asJson = flagBool(args, "json");
7253
+ const out = asJson ? logErr : log;
7254
+ const cases = [];
7255
+ if (args._.length > 0) {
7256
+ for (const given of args._) {
7257
+ const candidates = [resolve8(given), resolve8(testsDir, given), resolve8(testsDir, `${given}.flow.json`)];
7258
+ const abs = candidates.find((p) => existsSync20(p));
7259
+ if (!abs)
7260
+ throw new CliError(`No such test: "${given}" (looked in ${testsDir}).`);
7261
+ cases.push(readFlowFile(abs, given));
7262
+ }
7263
+ } else if (existsSync20(testsDir)) {
7264
+ for (const f2 of readdirSync9(testsDir).filter((f3) => f3.endsWith(".flow.json")).sort()) {
7265
+ cases.push(readFlowFile(resolve8(testsDir, f2), f2));
7266
+ }
7267
+ }
7268
+ const flowsOnly = flagBool(args, "flows");
7269
+ const wantFlows = !flagBool(args, "no-flows") && args._.length === 0;
7270
+ if (flowsOnly)
7271
+ cases.length = 0;
7272
+ let skipped = [];
7273
+ let entryScreen = null;
7274
+ if ((wantFlows || flowsOnly) && existsSync20(designMetaPath)) {
7275
+ let meta;
7276
+ try {
7277
+ meta = JSON.parse(readFileSync21(designMetaPath, "utf8"));
7278
+ } catch (e) {
7279
+ throw new CliError(`Can't read ${designMetaPath}: ${e instanceof Error ? e.message : String(e)}`);
7280
+ }
7281
+ if ((meta.edges ?? []).length > 0) {
7282
+ note("Probing the start screen…");
7283
+ const probe = await renderer.createSession(js, { raster: false, localAssets: useLocalAssets });
7284
+ entryScreen = probe.screenName();
7285
+ probe.dispose();
7286
+ if (entryScreen === null) {
7287
+ warnErr('Design flows skipped: the app\'s start screen root has no `name`. Name each screen root with its design screen id (e.g. UIScreen([...], { name: "login" })).');
7288
+ } else {
7289
+ const derived = deriveDesignFlows(meta, entryScreen);
7290
+ cases.push(...derived.flows);
7291
+ skipped = derived.skipped;
7292
+ }
7293
+ }
7294
+ } else if (flowsOnly) {
7295
+ throw new CliError(`--flows needs a design graph (${designMetaPath} not found).`);
7296
+ }
7297
+ if (cases.length === 0) {
7298
+ for (const w of warnings)
7299
+ warnErr(w);
7300
+ throw new CliError(`No tests found. Write tests/*.flow.json scenarios, or draw activator edges in the design board (design/meta.json).`);
7301
+ }
7302
+ const timeoutMs = Number(flagStr(args, "timeout")) || 20000 + cases.length * 1e4;
7303
+ const killer = setTimeout(() => {
7304
+ warnErr(`Test run timed out after ${timeoutMs}ms (raise --timeout or lower --settle).`);
7305
+ process.exit(1);
7306
+ }, timeoutMs);
7307
+ killer.unref?.();
7308
+ const onConsole = flagBool(args, "logs") ? (level, text) => logErr(`${c.dim(`[${level}]`)} ${text}`) : undefined;
7309
+ const artifactsRoot = resolve8(root, flagStr(args, "artifacts") ?? resolve8(testsDir, ".artifacts"));
7310
+ const results = [];
7311
+ for (const kase of cases) {
7312
+ const flagSettle = flagStr(args, "settle");
7313
+ let result;
7314
+ try {
7315
+ const steps = renderer.parseScenarioSteps(kase.steps);
7316
+ const session = await renderer.createSession(js, {
7317
+ width: Number(flagStr(args, "width")) || kase.width || 390,
7318
+ height: Number(flagStr(args, "height")) || kase.height || 844,
7319
+ settleMs: flagSettle !== undefined ? Number(flagSettle) : kase.settle,
7320
+ waitNetwork: flagBool(args, "wait-network"),
7321
+ localAssets: useLocalAssets,
7322
+ ...resolveSafeAreaChoice(flagStr(args, "safe-area") ?? kase.safeArea, flagStr(args, "device") ?? kase.device),
7323
+ onConsole
7324
+ });
7325
+ try {
7326
+ const io = fileScenarioIO(resolve8(artifactsRoot, slug3(kase.name)));
7327
+ const outcome = await renderer.runScenario(session, steps, io);
7328
+ result = {
7329
+ name: kase.name,
7330
+ kind: kase.kind,
7331
+ ok: outcome.ok,
7332
+ steps: outcome.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error })),
7333
+ artifacts: outcome.failureArtifacts.map((p) => resolve8(artifactsRoot, slug3(kase.name), p))
7334
+ };
7335
+ } finally {
7336
+ session.dispose();
7337
+ }
7338
+ } catch (e) {
7339
+ result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: e instanceof Error ? e.message : String(e), artifacts: [] };
7340
+ }
7341
+ results.push(result);
7342
+ if (result.ok) {
7343
+ out(`${c.green("✓")} ${result.name}`);
7344
+ } else {
7345
+ out(`${c.red("✗")} ${result.name}`);
7346
+ if (result.error)
7347
+ out(` ${c.red(result.error)}`);
7348
+ for (const s of result.steps) {
7349
+ out(s.ok ? c.dim(` ✓ ${s.desc}`) : ` ${c.red(`✗ ${s.desc} — ${s.error}`)}`);
7350
+ }
7351
+ if (result.artifacts.length > 0)
7352
+ out(c.dim(` failing frame: ${result.artifacts.join(", ")}`));
7353
+ }
7354
+ }
7355
+ for (const w of warnings)
7356
+ warnErr(w);
7357
+ const passed = results.filter((r) => r.ok).length;
7358
+ const failed = results.length - passed;
7359
+ if (skipped.length > 0) {
7360
+ out("");
7361
+ out(c.dim(`Not machine-verifiable (${skipped.length}):`));
7362
+ for (const s of skipped)
7363
+ out(c.dim(` - ${s.name}: ${s.reason}`));
7364
+ }
7365
+ out("");
7366
+ const flowCount = results.filter((r) => r.kind === "flow").length;
7367
+ const summary = [
7368
+ `${passed} passed`,
7369
+ failed > 0 ? c.red(`${failed} failed`) : undefined,
7370
+ skipped.length > 0 ? `${skipped.length} unverifiable` : undefined,
7371
+ flowCount > 0 ? c.dim(`(${flowCount} from the design graph${entryScreen ? `, start: ${entryScreen}` : ""})`) : undefined
7372
+ ].filter(Boolean).join(", ");
7373
+ out(failed > 0 ? `${c.red("✗")} ${summary}` : `${c.green("✓")} ${summary}`);
7374
+ if (asJson) {
7375
+ process.stdout.write(JSON.stringify({
7376
+ ok: failed === 0,
7377
+ passed,
7378
+ failed,
7379
+ entryScreen,
7380
+ cases: results,
7381
+ unverifiable: skipped
7382
+ }, null, 2) + `
7383
+ `);
7384
+ }
7385
+ clearTimeout(killer);
7386
+ process.exit(failed > 0 ? 1 : 0);
7387
+ };
7388
+
7026
7389
  // src/index.ts
7027
- import { existsSync as existsSync21, readFileSync as readFileSync21, realpathSync as realpathSync2 } from "node:fs";
7390
+ import { existsSync as existsSync22, readFileSync as readFileSync23, realpathSync as realpathSync2 } from "node:fs";
7028
7391
  import { createRequire as createRequire4 } from "node:module";
7029
- import { delimiter as delimiter2, dirname as dirname14, join as join23 } from "node:path";
7392
+ import { delimiter as delimiter2, dirname as dirname15, join as join23 } from "node:path";
7030
7393
 
7031
7394
  // src/commands/install.ts
7032
7395
  import { execFileSync as execFileSync2 } from "node:child_process";
7033
- import { appendFileSync as appendFileSync2, chmodSync, cpSync as cpSync2, existsSync as existsSync20, mkdirSync as mkdirSync14, readFileSync as readFileSync20, realpathSync, rmSync as rmSync3, symlinkSync } from "node:fs";
7396
+ import { appendFileSync as appendFileSync2, chmodSync, cpSync as cpSync2, existsSync as existsSync21, mkdirSync as mkdirSync15, readFileSync as readFileSync22, realpathSync, rmSync as rmSync3, symlinkSync } from "node:fs";
7034
7397
  import { homedir as homedir3 } from "node:os";
7035
- import { basename as basename12, delimiter, dirname as dirname13, join as join22, resolve as resolve8, sep } from "node:path";
7398
+ import { basename as basename12, delimiter, dirname as dirname14, join as join22, resolve as resolve9, sep } from "node:path";
7036
7399
  var isWin = process.platform === "win32";
7037
7400
  var HOME = homedir3();
7038
7401
  var safeRealpath = (p) => {
7039
7402
  try {
7040
7403
  return realpathSync(p);
7041
7404
  } catch {
7042
- return resolve8(p);
7405
+ return resolve9(p);
7043
7406
  }
7044
7407
  };
7045
7408
  var onPath = (dir) => {
@@ -7055,19 +7418,19 @@ var onPath = (dir) => {
7055
7418
  });
7056
7419
  };
7057
7420
  var install = async (args) => {
7058
- const bundle = dirname13(process.execPath);
7421
+ const bundle = dirname14(process.execPath);
7059
7422
  const exeName = basename12(process.execPath);
7060
7423
  const chiselName = isWin ? "chisel.exe" : "chisel";
7061
- if (!existsSync20(join22(bundle, "runtime")) || !existsSync20(join22(bundle, chiselName))) {
7424
+ if (!existsSync21(join22(bundle, "runtime")) || !existsSync21(join22(bundle, chiselName))) {
7062
7425
  throw new CliError("`lecodes install` only works from the packaged release build (the archive containing lecodes + " + "chisel + runtime/). You appear to be running from source or an npm install — install globally " + "with your package manager there instead.");
7063
7426
  }
7064
- const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync20(join22(bundle, f2)));
7427
+ const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync21(join22(bundle, f2)));
7065
7428
  if (isWin)
7066
7429
  return installWindows(args, bundle, companions);
7067
7430
  return installUnix(args, bundle, companions);
7068
7431
  };
7069
7432
  var copyBundle = (bundle, target, companions) => {
7070
- mkdirSync14(target, { recursive: true });
7433
+ mkdirSync15(target, { recursive: true });
7071
7434
  if (safeRealpath(target) === safeRealpath(bundle))
7072
7435
  return;
7073
7436
  for (const f2 of companions) {
@@ -7085,8 +7448,8 @@ var permError = (e, dir, system) => {
7085
7448
  };
7086
7449
  var installUnix = (args, bundle, companions) => {
7087
7450
  const system = flagBool(args, "system");
7088
- const libDir = resolve8(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join22(HOME, ".local", "lib", "lecodes-cli")));
7089
- const binDir = resolve8(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join22(HOME, ".local", "bin")));
7451
+ const libDir = resolve9(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join22(HOME, ".local", "lib", "lecodes-cli")));
7452
+ const binDir = resolve9(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join22(HOME, ".local", "bin")));
7090
7453
  const link = join22(binDir, "lecodes");
7091
7454
  try {
7092
7455
  copyBundle(bundle, libDir, companions);
@@ -7096,7 +7459,7 @@ var installUnix = (args, bundle, companions) => {
7096
7459
  throw permError(e, libDir, system);
7097
7460
  }
7098
7461
  try {
7099
- mkdirSync14(binDir, { recursive: true });
7462
+ mkdirSync15(binDir, { recursive: true });
7100
7463
  rmSync3(link, { force: true });
7101
7464
  symlinkSync(join22(libDir, "lecodes"), link);
7102
7465
  } catch (e) {
@@ -7119,7 +7482,7 @@ var appendPathToShellRc = (binDir) => {
7119
7482
  const { file, name } = shellRc();
7120
7483
  const line = `export PATH="${binDir}:$PATH"`;
7121
7484
  try {
7122
- const existing = existsSync20(file) ? readFileSync20(file, "utf8") : "";
7485
+ const existing = existsSync21(file) ? readFileSync22(file, "utf8") : "";
7123
7486
  if (existing.includes(line))
7124
7487
  return name;
7125
7488
  appendFileSync2(file, `${existing && !existing.endsWith(`
@@ -7157,7 +7520,7 @@ var finishPathUnix = (args, binDir) => {
7157
7520
  log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`);
7158
7521
  };
7159
7522
  var installWindows = (args, bundle, companions) => {
7160
- const target = resolve8(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
7523
+ const target = resolve9(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
7161
7524
  try {
7162
7525
  copyBundle(bundle, target, companions);
7163
7526
  } catch (e) {
@@ -7194,9 +7557,9 @@ var addToUserPathWindows = (dir) => {
7194
7557
 
7195
7558
  // src/index.ts
7196
7559
  var STANDALONE = (() => {
7197
- const dir = dirname14(process.execPath);
7560
+ const dir = dirname15(process.execPath);
7198
7561
  const chisel = process.platform === "win32" ? "chisel.exe" : "chisel";
7199
- return existsSync21(join23(dir, "runtime")) && existsSync21(join23(dir, chisel));
7562
+ return existsSync22(join23(dir, "runtime")) && existsSync22(join23(dir, chisel));
7200
7563
  })();
7201
7564
  var INSTALLED = STANDALONE && (() => {
7202
7565
  const exe = process.platform === "win32" ? "lecodes.exe" : "lecodes";
@@ -7212,7 +7575,7 @@ var INSTALLED = STANDALONE && (() => {
7212
7575
  return false;
7213
7576
  try {
7214
7577
  const cand = join23(d, exe);
7215
- return existsSync21(cand) && realpathSync2(cand) === self;
7578
+ return existsSync22(cand) && realpathSync2(cand) === self;
7216
7579
  } catch {
7217
7580
  return false;
7218
7581
  }
@@ -7275,12 +7638,28 @@ ${c.bold("Commands:")}
7275
7638
  -o, --out <file> write JSON to a file instead of stdout
7276
7639
  --png [file] rasterize a PNG (default screenshot.png;
7277
7640
  needs @napi-rs/canvas)
7641
+ --script <file> drive the app through a step list (tap/type/
7642
+ scroll/wait/expect/screenshot/json steps)
7643
+ --out-dir <dir> where --script step outputs land (default .)
7278
7644
  --scale <n> PNG device-pixel scale (default 2)
7279
7645
  --width/--height viewport size (default 390x844)
7280
7646
  --entry <path> override the entrypoint
7281
7647
  --settle <ms> wait for promises/timers/fetch before snapshot
7282
7648
  --wait-network let data fetches hit the real network
7283
7649
  --logs forward the app's console output (to stderr)
7650
+ test [name…] Run headless flow tests: tests/*.flow.json scenarios, plus flows
7651
+ derived from the design graph (design/meta.json activator edges
7652
+ — checks the built app against the designed navigation; needs
7653
+ named elements + screen roots). Failing steps dump the frame
7654
+ (JSON + PNG) under tests/.artifacts/<flow>/.
7655
+ --flows run ONLY the design-derived flows
7656
+ --no-flows run ONLY the tests/*.flow.json files
7657
+ --json machine-readable report on stdout
7658
+ --dir <folder> tests folder (default tests/)
7659
+ --design-dir <dir> design folder (default design/)
7660
+ --artifacts <dir> failure-artifact folder override
7661
+ (+ the render timing flags: --width/--height, --settle,
7662
+ --device, --safe-area, --wait-network, --logs, --entry)
7284
7663
  design [serve] LeCodes Design — a live canvas of screens for AI-driven prototyping
7285
7664
  (needs the optional 'lecodes-design' package; works inside a
7286
7665
  cloned project or standalone in any folder)
@@ -7313,7 +7692,8 @@ ${c.bold("Commands:")}
7313
7692
  keeps heavy assets streaming from the server), pick the SDK
7314
7693
  variant from the bundle's engine use, vendor app.json
7315
7694
  plugins (pinned in plugins.lock.json), regenerate the
7316
- CLI-owned shell files
7695
+ CLI-owned shell files. app.json "update": true (or
7696
+ {"url": …}) enables native OTA JS updates at launch
7317
7697
  app plugin <list|add|remove> Manage native plugins in app.json (then run 'lecodes app sync')
7318
7698
  add takes a known id (camera, qr-scanner) or github:owner/repo[@tag]
7319
7699
  app open [ios] Open the native shell in its IDE (Xcode; Android Studio later)
@@ -7328,7 +7708,7 @@ ${c.bold("Commands:")}
7328
7708
  ASC API key; stored in ~/.lecodes/asc.json
7329
7709
 
7330
7710
  Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKEN).`;
7331
- var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, design, scene, dev, app };
7711
+ var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, test, design, scene, dev, app };
7332
7712
  if (STANDALONE)
7333
7713
  commands.install = install;
7334
7714
  var readVersion = () => package_default.version ?? "unknown";
@@ -7340,15 +7720,15 @@ var OPTIONAL_PEERS = [
7340
7720
  var peerVersion = (name, probe) => {
7341
7721
  try {
7342
7722
  const require2 = createRequire4(import.meta.url);
7343
- let dir = dirname14(require2.resolve(probe));
7723
+ let dir = dirname15(require2.resolve(probe));
7344
7724
  for (let depth = 0;depth < 16; depth++) {
7345
7725
  const manifest = join23(dir, "package.json");
7346
- if (existsSync21(manifest)) {
7347
- const parsed = JSON.parse(readFileSync21(manifest, "utf8"));
7726
+ if (existsSync22(manifest)) {
7727
+ const parsed = JSON.parse(readFileSync23(manifest, "utf8"));
7348
7728
  if (parsed.name === name)
7349
7729
  return parsed.version ?? "unknown";
7350
7730
  }
7351
- const parent = dirname14(dir);
7731
+ const parent = dirname15(dir);
7352
7732
  if (parent === dir)
7353
7733
  break;
7354
7734
  dir = parent;