lecodes-cli 0.7.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/index.js +3005 -725
  2. package/package.json +2 -2
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/canvas/Canvas.ts +16 -0
  5. package/runtime/sdk/gl/Material.ts +4 -0
  6. package/runtime/sdk/inject.ts +7 -4
  7. package/runtime/sdk/plugins/service.ts +47 -0
  8. package/runtime/sdk/runtime/app.ts +21 -1
  9. package/runtime/sdk/runtime/appEvents.ts +25 -0
  10. package/runtime/sdk/runtime/service.ts +3 -2
  11. package/runtime/sdk/ui/NativeView.ts +2 -1
  12. package/runtime/sdk/ui/UI.ts +3 -1
  13. package/runtime/sdk/ui/UIButton.ts +10 -5
  14. package/runtime/sdk/ui/UIContainer.ts +16 -9
  15. package/runtime/sdk/ui/UIImage.ts +5 -2
  16. package/runtime/sdk/ui/UIInput.ts +75 -8
  17. package/runtime/sdk/ui/UIModal.ts +149 -0
  18. package/runtime/sdk/ui/UINode.ts +113 -50
  19. package/runtime/sdk/ui/UIPager.ts +297 -0
  20. package/runtime/sdk/ui/UIPopover.ts +106 -0
  21. package/runtime/sdk/ui/UIScreen.ts +11 -10
  22. package/runtime/sdk/ui/UIScrollable.ts +2 -1
  23. package/runtime/sdk/ui/UISpacer.ts +1 -1
  24. package/runtime/sdk/ui/UIText.ts +2 -1
  25. package/runtime/sdk/ui/UIVideo.ts +2 -1
  26. package/runtime/sdk/ui/UIVirtualizedList.ts +2 -1
  27. package/runtime/sdk/ui/UIWidget.ts +19 -22
  28. package/runtime/sdk/ui/theme.ts +66 -0
  29. package/runtime/sdk-types.json +1 -1
  30. package/runtime/sdk/compile/aspectMacro.ts +0 -42
  31. package/runtime/sdk/compile/assetMacro.ts +0 -45
  32. package/runtime/sdk/compile/bundler.ts +0 -207
  33. package/runtime/sdk/compile/compileProject.ts +0 -113
  34. package/runtime/sdk/compile/header.ts +0 -46
  35. package/runtime/sdk/compile/index.ts +0 -18
  36. package/runtime/sdk/compile/libraryImports.ts +0 -47
  37. package/runtime/sdk/compile/sceneEditor.ts +0 -78
  38. package/runtime/sdk/compile/sourcemap.ts +0 -25
  39. package/runtime/sdk/kit/UITabs.ts +0 -105
  40. package/runtime/sdk/ui/UIScreenHost.ts +0 -213
package/dist/index.js CHANGED
@@ -109,12 +109,15 @@ var hasImportStatement = (src) => src.includes("import") || src.includes("from")
109
109
  if (isRelativeOrAbsolute(spec))
110
110
  return spec;
111
111
  const slash = spec.indexOf("/");
112
- const slug2 = slash === -1 ? spec : spec.slice(0, slash);
113
- if (slugs.has(slug2)) {
112
+ const slug = slash === -1 ? spec : spec.slice(0, slash);
113
+ if (slugs.has(slug)) {
114
114
  const sub = slash === -1 ? "/index" : spec.slice(slash);
115
- return `/__lib/${slug2}${sub}`;
115
+ return `/__lib/${slug}${sub}`;
116
116
  }
117
- throw new Error(`Unknown library "${slug2}" — add it in Settings → Libraries, or use a relative path like "./${slug2}".`);
117
+ if (slug === "ui" || slug === "creator" || slug === "utils") {
118
+ throw new Error(`import from "${slug}": the SDK needs no imports — UIScreen, Scene, etc. are globals; remove this import line.`);
119
+ }
120
+ throw new Error(`Unknown library "${slug}" — add it in Settings → Libraries, or use a relative path like "./${slug}".`);
118
121
  };
119
122
  let out = src.replace(IMPORT_FROM_RE, (_m, boundary, head, quote, spec) => `${boundary}${head}${quote}${rewriteSpec(spec)}${quote}`);
120
123
  out = out.replace(SIDE_EFFECT_RE, (m, boundary, head, quote, spec) => isRelativeOrAbsolute(spec) ? m : `${boundary}${head}${quote}${rewriteSpec(spec)}${quote}`);
@@ -126,18 +129,18 @@ var init_libraryImports = __esm(() => {
126
129
  });
127
130
 
128
131
  // ../sdk/src/compile/bundler.ts
129
- import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync8, statSync } from "node:fs";
132
+ import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync } from "node:fs";
130
133
  import { spawnSync } from "node:child_process";
131
134
  import { createRequire as createRequire2 } from "node:module";
132
- import { dirname as dirname5, join as join8, relative as relative2 } from "node:path";
135
+ import { dirname as dirname4, join as join7, relative as relative2 } from "node:path";
133
136
  import { fileURLToPath as fileURLToPath2 } from "node:url";
134
137
  var resolveInject = () => {
135
138
  const override = process.env.LECODES_SDK_INJECT;
136
- if (override && existsSync6(override))
139
+ if (override && existsSync4(override))
137
140
  return override;
138
- const here = dirname5(fileURLToPath2(import.meta.url));
139
- const src = join8(here, "..", "inject.ts");
140
- if (existsSync6(src))
141
+ const here = dirname4(fileURLToPath2(import.meta.url));
142
+ const src = join7(here, "..", "inject.ts");
143
+ if (existsSync4(src))
141
144
  return src;
142
145
  throw new Error("sdk: could not locate the inject source (LECODES_SDK_INJECT / src/inject.ts)");
143
146
  }, normalize = (path) => {
@@ -156,19 +159,19 @@ var resolveInject = () => {
156
159
  if (!inject.endsWith("inject.ts")) {
157
160
  throw new Error(`chisel needs the SDK source tree (src/inject.ts); got ${inject}. Set LECODES_SDK_INJECT to the source.`);
158
161
  }
159
- return dirname5(inject);
162
+ return dirname4(inject);
160
163
  }, isSdkSource = (path) => /\.[tj]s$/.test(path) && !path.endsWith(".d.ts") && !path.includes("/compile/") && !path.includes("/tests/") && !path.includes("/__tests__/"), sdkFilesCache, collectSdkFiles = (sdkSrc) => {
161
164
  const cached = sdkFilesCache.get(sdkSrc);
162
165
  if (cached)
163
166
  return cached;
164
167
  const out = {};
165
168
  const walk = (dir) => {
166
- for (const name of readdirSync3(dir)) {
167
- const p = join8(dir, name);
169
+ for (const name of readdirSync2(dir)) {
170
+ const p = join7(dir, name);
168
171
  if (statSync(p).isDirectory())
169
172
  walk(p);
170
173
  else if (isSdkSource(p))
171
- out["/__sdk/" + relative2(sdkSrc, p).split("\\").join("/")] = readFileSync8(p, "utf8");
174
+ out["/__sdk/" + relative2(sdkSrc, p).split("\\").join("/")] = readFileSync7(p, "utf8");
172
175
  }
173
176
  };
174
177
  walk(sdkSrc);
@@ -177,13 +180,13 @@ var resolveInject = () => {
177
180
  }, resolveChiselBin = () => {
178
181
  const override = process.env.LECODES_CHISEL_BIN;
179
182
  if (override) {
180
- if (existsSync6(override))
183
+ if (existsSync4(override))
181
184
  return override;
182
185
  throw new Error(`LECODES_CHISEL_BIN points at a missing file: ${override}`);
183
186
  }
184
187
  try {
185
188
  const bin = createRequire2(import.meta.url)("@letary/chisel").binaryPath();
186
- if (bin && existsSync6(bin))
189
+ if (bin && existsSync4(bin))
187
190
  return bin;
188
191
  } catch {}
189
192
  throw new Error("chisel binary not found. Install @letary/chisel, or set LECODES_CHISEL_BIN.");
@@ -206,9 +209,9 @@ var resolveInject = () => {
206
209
  }
207
210
  if (!projFiles[entry])
208
211
  throw new Error(`Entrypoint not found: ${entry}`);
209
- for (const [slug2, libFiles] of Object.entries(libraries ?? {})) {
212
+ for (const [slug, libFiles] of Object.entries(libraries ?? {})) {
210
213
  for (const [k, v] of Object.entries(libFiles)) {
211
- const path = normalize(`/__lib/${slug2}/${k}`);
214
+ const path = normalize(`/__lib/${slug}/${k}`);
212
215
  let src = v;
213
216
  if (/\.[tj]sx?$/.test(path)) {
214
217
  if (hasAssetMacro(src))
@@ -290,6 +293,9 @@ var buildHeader = (name, code, opts = {}) => {
290
293
  localAssets.add("capture");
291
294
  if (localAssets.size > 0)
292
295
  header += `// preload: ${[...localAssets].join(";")}
296
+ `;
297
+ if (opts.fonts)
298
+ header += `// fonts: ${opts.fonts}
293
299
  `;
294
300
  if (!code.includes("_creator.createScene") && !code.includes("_creator.createEntity"))
295
301
  header += `// gl: disable
@@ -339,7 +345,7 @@ var detectEntry = (entries) => {
339
345
  const files = {};
340
346
  for (const e of opts.entries) {
341
347
  if (e.type === "resource") {
342
- if (opts.localAssets) {
348
+ if (opts.localAssets && !e.remote) {
343
349
  const name = e.path.split(/[\\/]/).pop() ?? e.path;
344
350
  files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(name)}))`;
345
351
  } else {
@@ -365,7 +371,7 @@ var detectEntry = (entries) => {
365
371
  return map ? `${code}
366
372
  ${inlineSourceMapComment(map)}` : code;
367
373
  }
368
- const header = buildHeader(opts.name, code, { icon: opts.icon });
374
+ const header = buildHeader(opts.name, code, { icon: opts.icon, fonts: opts.fontsHeader });
369
375
  if (!map)
370
376
  return header + code;
371
377
  const headerLines = (header.match(/\n/g) ?? []).length;
@@ -377,17 +383,17 @@ var init_compileProject = __esm(() => {
377
383
  });
378
384
 
379
385
  // ../sdk/src/compile/sceneEditor.ts
380
- import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync9 } from "node:fs";
381
- import { join as join9 } from "node:path";
386
+ import { existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync8 } from "node:fs";
387
+ import { join as join8 } from "node:path";
382
388
  var SCENE_HARNESS_DIR = "./.scene-editor", SCENE_ENTRY_PATH = "./.scene-entry.ts", loadSceneHarness = (fallbackDir) => {
383
389
  const override = process.env.LECODES_SCENE_HARNESS_DIR;
384
- const dir = override && existsSync7(override) ? override : fallbackDir && existsSync7(fallbackDir) ? fallbackDir : null;
390
+ const dir = override && existsSync5(override) ? override : fallbackDir && existsSync5(fallbackDir) ? fallbackDir : null;
385
391
  if (!dir)
386
392
  throw new Error("scene-editor harness sources not found (set LECODES_SCENE_HARNESS_DIR)");
387
393
  const out = {};
388
- for (const f of readdirSync4(dir)) {
394
+ for (const f of readdirSync3(dir)) {
389
395
  if (f.endsWith(".ts"))
390
- out[f] = readFileSync9(join9(dir, f), "utf8");
396
+ out[f] = readFileSync8(join8(dir, f), "utf8");
391
397
  }
392
398
  return out;
393
399
  }, sceneEditorEntries = (scenePath, harness, projectPaths = []) => {
@@ -417,25 +423,569 @@ export {}
417
423
  };
418
424
  var init_sceneEditor = () => {};
419
425
 
426
+ // ../sdk/src/compile/fontRegistry.ts
427
+ var DEFAULT_FONT_REGISTRY_BASE = "https://cdn.le.codes/fonts/v1", fontRegistryBase = () => typeof process !== "undefined" && process.env?.LECODES_FONT_REGISTRY?.trim() || DEFAULT_FONT_REGISTRY_BASE, faceFileName = (id, weight, italic, ext2) => `${id}-${weight}${italic ? "-italic" : ""}.${ext2}`, LATIN, CYR, OFL = "OFL-1.1", f = (family, category, scripts, weights, italics, defaultWeights) => ({ family, category, scripts, weights, italics, defaultWeights, license: OFL }), FONT_REGISTRY;
428
+ var init_fontRegistry = __esm(() => {
429
+ LATIN = ["latin"];
430
+ CYR = ["latin", "cyrillic"];
431
+ FONT_REGISTRY = {
432
+ inter: f("Inter", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [], [400, 500, 700]),
433
+ manrope: f("Manrope", "sans-serif", CYR, [200, 300, 400, 500, 600, 700, 800], [], [400, 500, 700]),
434
+ "golos-text": f("Golos Text", "sans-serif", CYR, [400, 500, 600, 700, 800, 900], [], [400, 500, 700]),
435
+ onest: f("Onest", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [], [400, 500, 700]),
436
+ rubik: f("Rubik", "sans-serif", CYR, [300, 400, 500, 600, 700, 800, 900], [300, 400, 500, 600, 700, 800, 900], [400, 500, 700]),
437
+ nunito: f("Nunito", "sans-serif", CYR, [200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 600, 700]),
438
+ montserrat: f("Montserrat", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 500, 700]),
439
+ "ibm-plex-sans": f("IBM Plex Sans", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700], [400, 700], [400, 500, 700]),
440
+ "pt-sans": f("PT Sans", "sans-serif", CYR, [400, 700], [400, 700], [400, 700]),
441
+ jost: f("Jost", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 500, 700]),
442
+ "exo-2": f("Exo 2", "sans-serif", CYR, [100, 200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 500, 700]),
443
+ unbounded: f("Unbounded", "display", CYR, [200, 300, 400, 500, 600, 700, 800, 900], [], [400, 700]),
444
+ "russo-one": f("Russo One", "display", CYR, [400], [], [400]),
445
+ oswald: f("Oswald", "display", CYR, [200, 300, 400, 500, 600, 700], [], [400, 700]),
446
+ comfortaa: f("Comfortaa", "display", CYR, [300, 400, 500, 600, 700], [], [400, 700]),
447
+ "space-grotesk": f("Space Grotesk", "display", LATIN, [300, 400, 500, 600, 700], [], [400, 700]),
448
+ "playfair-display": f("Playfair Display", "serif", CYR, [400, 500, 600, 700, 800, 900], [400, 700], [400, 700]),
449
+ lora: f("Lora", "serif", CYR, [400, 500, 600, 700], [400, 700], [400, 700]),
450
+ "pt-serif": f("PT Serif", "serif", CYR, [400, 700], [400, 700], [400, 700]),
451
+ cormorant: f("Cormorant", "serif", CYR, [300, 400, 500, 600, 700], [400, 700], [400, 700]),
452
+ literata: f("Literata", "serif", CYR, [200, 300, 400, 500, 600, 700, 800, 900], [400, 700], [400, 700]),
453
+ "jetbrains-mono": f("JetBrains Mono", "monospace", CYR, [100, 200, 300, 400, 500, 600, 700, 800], [400, 700], [400, 700]),
454
+ "fira-code": f("Fira Code", "monospace", CYR, [300, 400, 500, 600, 700], [], [400, 700]),
455
+ "ibm-plex-mono": f("IBM Plex Mono", "monospace", CYR, [100, 200, 300, 400, 500, 600, 700], [400, 700], [400, 700]),
456
+ caveat: f("Caveat", "handwriting", CYR, [400, 500, 600, 700], [], [400, 700]),
457
+ pacifico: f("Pacifico", "handwriting", CYR, [400], [], [400])
458
+ };
459
+ });
460
+
461
+ // ../sdk/src/compile/sfnt.ts
462
+ var TAG_TTF = 65536, TAG_OTTO = 1330926671, TAG_TTCF = 1953784678, TAG_WOFF = 2001684038, TAG_WOF2 = 2001684018, parseSfnt = (bytes) => {
463
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
464
+ if (bytes.byteLength < 12)
465
+ throw new Error("not a font file (truncated)");
466
+ const version = dv.getUint32(0);
467
+ if (version === TAG_TTCF)
468
+ throw new Error("font collections (.ttc) aren't supported — export a single face");
469
+ if (version === TAG_WOFF || version === TAG_WOF2)
470
+ throw new Error("WOFF/WOFF2 project fonts aren't supported — use the .ttf/.otf");
471
+ if (version !== TAG_TTF && version !== TAG_OTTO)
472
+ throw new Error("not a TTF/OTF font file");
473
+ const numTables = dv.getUint16(4);
474
+ const tables = new Map;
475
+ for (let i = 0;i < numTables; i++) {
476
+ const rec = 12 + i * 16;
477
+ if (rec + 16 > bytes.byteLength)
478
+ throw new Error("not a font file (truncated table directory)");
479
+ const tag = String.fromCharCode(bytes[rec], bytes[rec + 1], bytes[rec + 2], bytes[rec + 3]);
480
+ tables.set(tag, { offset: dv.getUint32(rec + 8), length: dv.getUint32(rec + 12) });
481
+ }
482
+ const family = readFamily(dv, bytes, tables.get("name"));
483
+ if (!family)
484
+ throw new Error("no family name in the font's `name` table");
485
+ let weight = 400;
486
+ let italic = false;
487
+ const os2 = tables.get("OS/2");
488
+ if (os2 && os2.offset + 64 <= bytes.byteLength) {
489
+ weight = dv.getUint16(os2.offset + 4);
490
+ italic = (dv.getUint16(os2.offset + 62) & 1) === 1;
491
+ } else {
492
+ const head = tables.get("head");
493
+ if (head && head.offset + 46 <= bytes.byteLength) {
494
+ const macStyle = dv.getUint16(head.offset + 44);
495
+ if (macStyle & 1)
496
+ weight = 700;
497
+ italic = (macStyle & 2) === 2;
498
+ }
499
+ }
500
+ if (weight < 1 || weight > 1000)
501
+ weight = 400;
502
+ return { family, weight, italic };
503
+ }, readFamily = (dv, bytes, name) => {
504
+ if (!name || name.offset + 6 > bytes.byteLength)
505
+ return "";
506
+ const base = name.offset;
507
+ const count = dv.getUint16(base + 2);
508
+ const stringOffset = base + dv.getUint16(base + 4);
509
+ let best = "";
510
+ let bestScore = -1;
511
+ for (let i = 0;i < count; i++) {
512
+ const rec = base + 6 + i * 12;
513
+ if (rec + 12 > bytes.byteLength)
514
+ break;
515
+ const platformID = dv.getUint16(rec);
516
+ const languageID = dv.getUint16(rec + 4);
517
+ const nameID = dv.getUint16(rec + 6);
518
+ const length = dv.getUint16(rec + 8);
519
+ const offset = stringOffset + dv.getUint16(rec + 10);
520
+ if (nameID !== 16 && nameID !== 1)
521
+ continue;
522
+ if (offset + length > bytes.byteLength)
523
+ continue;
524
+ let score = nameID === 16 ? 100 : 0;
525
+ if (platformID === 3)
526
+ score += languageID === 1033 ? 30 : 20;
527
+ else if (platformID === 0)
528
+ score += 10;
529
+ if (score <= bestScore)
530
+ continue;
531
+ const text = platformID === 1 ? decodeLatin1(bytes, offset, length) : decodeUtf16be(bytes, offset, length);
532
+ if (!text.trim())
533
+ continue;
534
+ best = text.trim();
535
+ bestScore = score;
536
+ }
537
+ return best;
538
+ }, decodeUtf16be = (bytes, offset, length) => {
539
+ let out = "";
540
+ for (let i = 0;i + 1 < length; i += 2)
541
+ out += String.fromCharCode(bytes[offset + i] << 8 | bytes[offset + i + 1]);
542
+ return out;
543
+ }, decodeLatin1 = (bytes, offset, length) => {
544
+ let out = "";
545
+ for (let i = 0;i < length; i++)
546
+ out += String.fromCharCode(bytes[offset + i]);
547
+ return out;
548
+ };
549
+
550
+ // ../sdk/src/compile/fontMacro.ts
551
+ var FONT_ID, CODE_FILE, DECLARES_FONT, isIdentStart = (c2) => c2 >= "A" && c2 <= "Z" || c2 >= "a" && c2 <= "z" || c2 === "_" || c2 === "$", isIdentPart = (c2) => isIdentStart(c2) || c2 >= "0" && c2 <= "9", skipString = (src, start) => {
552
+ const quote = src[start];
553
+ const n = src.length;
554
+ let i = start + 1;
555
+ while (i < n) {
556
+ const c2 = src[i];
557
+ if (c2 === "\\") {
558
+ i += 2;
559
+ continue;
560
+ }
561
+ if (quote === "`" && c2 === "$" && src[i + 1] === "{") {
562
+ i = skipBraces(src, i + 1);
563
+ continue;
564
+ }
565
+ if (c2 === quote)
566
+ return i + 1;
567
+ i++;
568
+ }
569
+ return n;
570
+ }, skipBraces = (src, open) => {
571
+ const n = src.length;
572
+ let depth = 0;
573
+ let i = open;
574
+ while (i < n) {
575
+ const c2 = src[i];
576
+ if (c2 === '"' || c2 === "'" || c2 === "`") {
577
+ i = skipString(src, i);
578
+ continue;
579
+ }
580
+ if (c2 === "{")
581
+ depth++;
582
+ else if (c2 === "}") {
583
+ depth--;
584
+ if (depth === 0)
585
+ return i + 1;
586
+ }
587
+ i++;
588
+ }
589
+ return n;
590
+ }, matchParen = (src, open) => {
591
+ const n = src.length;
592
+ let depth = 0;
593
+ let i = open;
594
+ while (i < n) {
595
+ const c2 = src[i];
596
+ if (c2 === '"' || c2 === "'" || c2 === "`") {
597
+ i = skipString(src, i);
598
+ continue;
599
+ }
600
+ if (c2 === "/" && src[i + 1] === "/") {
601
+ i += 2;
602
+ while (i < n && src[i] !== `
603
+ `)
604
+ i++;
605
+ continue;
606
+ }
607
+ if (c2 === "/" && src[i + 1] === "*") {
608
+ i += 2;
609
+ while (i < n && !(src[i] === "*" && src[i + 1] === "/"))
610
+ i++;
611
+ i += 2;
612
+ continue;
613
+ }
614
+ if (c2 === "(")
615
+ depth++;
616
+ else if (c2 === ")") {
617
+ depth--;
618
+ if (depth === 0)
619
+ return i;
620
+ }
621
+ i++;
622
+ }
623
+ return -1;
624
+ }, splitTopLevel = (text, delim) => {
625
+ const parts = [];
626
+ const n = text.length;
627
+ let depth = 0;
628
+ let last = 0;
629
+ let i = 0;
630
+ while (i < n) {
631
+ const c2 = text[i];
632
+ if (c2 === '"' || c2 === "'" || c2 === "`") {
633
+ i = skipString(text, i);
634
+ continue;
635
+ }
636
+ if (c2 === "(" || c2 === "[" || c2 === "{")
637
+ depth++;
638
+ else if (c2 === ")" || c2 === "]" || c2 === "}")
639
+ depth = Math.max(0, depth - 1);
640
+ else if (depth === 0 && c2 === delim) {
641
+ parts.push(text.slice(last, i));
642
+ last = i + 1;
643
+ }
644
+ i++;
645
+ }
646
+ parts.push(text.slice(last));
647
+ return parts;
648
+ }, stringLiteralValue = (text) => {
649
+ const t = text.trim();
650
+ if (t.length < 2)
651
+ return null;
652
+ const q = t[0];
653
+ if (q !== '"' && q !== "'" && q !== "`" || t[t.length - 1] !== q)
654
+ return null;
655
+ const inner = t.slice(1, -1);
656
+ if (q === "`" && inner.includes("${"))
657
+ return null;
658
+ if (inner.includes(q) && !inner.includes("\\" + q))
659
+ return null;
660
+ return inner.replace(/\\(.)/g, "$1");
661
+ }, prevNonSpace = (src, i) => {
662
+ let k = i - 1;
663
+ while (k >= 0 && /\s/.test(src[k]))
664
+ k--;
665
+ return k >= 0 ? src[k] : "";
666
+ }, scanFontCalls = (source) => {
667
+ const calls = [];
668
+ const n = source.length;
669
+ let i = 0;
670
+ while (i < n) {
671
+ const c2 = source[i];
672
+ if (c2 === '"' || c2 === "'" || c2 === "`") {
673
+ i = skipString(source, i);
674
+ continue;
675
+ }
676
+ if (c2 === "/" && source[i + 1] === "/") {
677
+ i += 2;
678
+ while (i < n && source[i] !== `
679
+ `)
680
+ i++;
681
+ continue;
682
+ }
683
+ if (c2 === "/" && source[i + 1] === "*") {
684
+ i += 2;
685
+ while (i < n && !(source[i] === "*" && source[i + 1] === "/"))
686
+ i++;
687
+ i += 2;
688
+ continue;
689
+ }
690
+ if (isIdentStart(c2)) {
691
+ let j = i + 1;
692
+ while (j < n && isIdentPart(source[j]))
693
+ j++;
694
+ if (source.slice(i, j) === "font" && prevNonSpace(source, i) !== ".") {
695
+ const call = parseCall(source, i, j);
696
+ if (call) {
697
+ calls.push(call);
698
+ i = call.end;
699
+ continue;
700
+ }
701
+ }
702
+ i = j;
703
+ continue;
704
+ }
705
+ i++;
706
+ }
707
+ return calls;
708
+ }, parseCall = (src, start, identEnd) => {
709
+ const n = src.length;
710
+ let i = identEnd;
711
+ while (i < n && /\s/.test(src[i]))
712
+ i++;
713
+ if (src[i] !== "(")
714
+ return null;
715
+ const close = matchParen(src, i);
716
+ if (close === -1)
717
+ return null;
718
+ const call = { start, end: close + 1, arg: "", kind: "registry" };
719
+ const args = splitTopLevel(src.slice(i + 1, close), ",");
720
+ const arg = stringLiteralValue(args[0] ?? "");
721
+ if (arg === null) {
722
+ call.error = `font(...) needs a string-literal argument — a registry id ("manrope") or a project path ("./fonts/Brand.ttf"). Got \`${(args[0] ?? "").trim() || "()"}\`.`;
723
+ return call;
724
+ }
725
+ call.arg = arg;
726
+ if (arg.startsWith("./") || arg.startsWith("../")) {
727
+ call.kind = "file";
728
+ if (!/\.(ttf|otf)$/i.test(arg)) {
729
+ call.error = `font("${arg}"): project fonts must be .ttf/.otf files.`;
730
+ return call;
731
+ }
732
+ if (args.length > 1 && args.slice(1).some((a) => a.trim())) {
733
+ call.error = `font("${arg}"): a project font takes no options — weight and style come from the file itself.`;
734
+ return call;
735
+ }
736
+ return call;
737
+ }
738
+ if (arg === "serif" || arg === "sans-serif" || arg === "monospace" || arg === "monospaced") {
739
+ call.error = `font("${arg}"): system font families need no font() — pass the string straight to fontFamily, e.g. .style({ fontFamily: "${arg}" }).`;
740
+ return call;
741
+ }
742
+ if (!FONT_ID.test(arg)) {
743
+ call.error = `font("${arg}"): expected a registry id (lowercase [a-z0-9-], e.g. "manrope") or a relative path ("./fonts/Brand.ttf").`;
744
+ return call;
745
+ }
746
+ call.kind = "registry";
747
+ call.id = arg;
748
+ const optsText = args[1]?.trim();
749
+ if (optsText) {
750
+ const err = parseOptions(optsText, call);
751
+ if (err) {
752
+ call.error = err;
753
+ return call;
754
+ }
755
+ }
756
+ return call;
757
+ }, parseOptions = (optsText, call) => {
758
+ if (!optsText.startsWith("{") || !optsText.endsWith("}")) {
759
+ return `font("${call.arg}", opts): opts must be an object literal like { weights: [400, 700] }.`;
760
+ }
761
+ for (const prop of splitTopLevel(optsText.slice(1, -1), ",")) {
762
+ if (!prop.trim())
763
+ continue;
764
+ const kv = splitTopLevel(prop, ":");
765
+ if (kv.length < 2)
766
+ return `font("${call.arg}"): couldn't parse option \`${prop.trim()}\`.`;
767
+ const key = kv[0].trim().replace(/^["'`]|["'`]$/g, "");
768
+ const raw = kv.slice(1).join(":").trim();
769
+ if (key === "weights") {
770
+ const m = /^\[\s*(\d+(?:\s*,\s*\d+)*)\s*,?\s*\]$/.exec(raw);
771
+ if (!m)
772
+ return `font("${call.arg}"): weights must be a literal number array, e.g. { weights: [400, 700] }.`;
773
+ call.weights = m[1].split(",").map((s) => parseInt(s.trim(), 10));
774
+ } else if (key === "italic") {
775
+ if (raw !== "true" && raw !== "false")
776
+ return `font("${call.arg}"): italic must be a literal true/false.`;
777
+ call.italic = raw === "true";
778
+ } else {
779
+ return `font("${call.arg}"): unknown option "${key}" (did you mean weights/italic?).`;
780
+ }
781
+ }
782
+ return null;
783
+ }, norm = (p) => p.replace(/\\/g, "/").replace(/^\//, ""), dirOf = (p) => {
784
+ const n = norm(p);
785
+ const i = n.lastIndexOf("/");
786
+ return i === -1 ? "" : n.slice(0, i);
787
+ }, baseOf = (p) => {
788
+ const n = norm(p);
789
+ return n.slice(n.lastIndexOf("/") + 1);
790
+ }, resolveRel = (dir, rel) => {
791
+ const out = dir ? dir.split("/") : [];
792
+ for (const seg of rel.split("/")) {
793
+ if (!seg || seg === ".")
794
+ continue;
795
+ if (seg === "..") {
796
+ if (out.length === 0)
797
+ return "";
798
+ out.pop();
799
+ } else
800
+ out.push(seg);
801
+ }
802
+ return out.join("/");
803
+ }, relSpecifier = (fromDir, toPath) => {
804
+ const from = fromDir ? fromDir.split("/") : [];
805
+ const to = norm(toPath).split("/");
806
+ let common = 0;
807
+ while (common < from.length && common < to.length - 1 && from[common] === to[common])
808
+ common++;
809
+ const ups = from.length - common;
810
+ const rest = to.slice(common).join("/");
811
+ return ups === 0 ? `./${rest}` : `${"../".repeat(ups)}${rest}`;
812
+ }, faceKey = (family, weight, italic) => `${family}|${weight}|${italic ? "i" : "n"}`, applyFontMacro = async (entries, opts) => {
813
+ const scanned = entries.filter((e) => e.type === "text" && CODE_FILE.test(e.path) && typeof e.text === "string" && e.text.includes("font(")).filter((e) => !DECLARES_FONT.test(e.text)).map((e) => ({ entry: e, calls: scanFontCalls(e.text) })).filter((s) => s.calls.length > 0);
814
+ if (scanned.length === 0)
815
+ return [];
816
+ const errors = scanned.flatMap((s) => s.calls.filter((c2) => c2.error).map((c2) => `${s.entry.path}: ${c2.error}`));
817
+ if (errors.length)
818
+ throw new Error([...new Set(errors)].join(`
819
+ `));
820
+ const registryBase = (opts.registryBase ?? fontRegistryBase()).replace(/\/+$/, "");
821
+ const entryByPath = new Map(entries.map((e) => [norm(e.path), e]));
822
+ const faces = new Map;
823
+ const fileFaceCache = new Map;
824
+ const resolveCall = (entryPath, call) => {
825
+ if (call.kind === "file") {
826
+ const filePath = resolveRel(dirOf(entryPath), call.arg);
827
+ const target = filePath && entryByPath.get(filePath);
828
+ if (!target)
829
+ throw new Error(`${entryPath}: font("${call.arg}"): no such file in the project (resolved to "${filePath || call.arg}").`);
830
+ call.filePath = filePath;
831
+ let info2 = fileFaceCache.get(filePath);
832
+ if (!info2) {
833
+ if (!opts.readBytes)
834
+ throw new Error(`${entryPath}: font("${call.arg}"): project-font files aren't supported in this compile context.`);
835
+ const bytes = opts.readBytes(filePath);
836
+ if (!bytes)
837
+ throw new Error(`${entryPath}: font("${call.arg}"): couldn't read "${filePath}".`);
838
+ try {
839
+ info2 = parseSfnt(bytes);
840
+ } catch (e) {
841
+ throw new Error(`${entryPath}: font("${call.arg}"): ${e.message}`);
842
+ }
843
+ fileFaceCache.set(filePath, info2);
844
+ }
845
+ return { family: info2.family, callFaces: [{ family: info2.family, weight: info2.weight, italic: info2.italic }] };
846
+ }
847
+ const meta = FONT_REGISTRY[call.id];
848
+ if (!meta) {
849
+ const known = Object.keys(FONT_REGISTRY);
850
+ throw new Error(`${entryPath}: font("${call.id}"): unknown registry font. Available: ${known.slice(0, 10).join(", ")}${known.length > 10 ? ", …" : ""}`);
851
+ }
852
+ const weights = call.weights ?? meta.defaultWeights;
853
+ const badW = weights.filter((w) => !meta.weights.includes(w));
854
+ if (badW.length)
855
+ throw new Error(`${entryPath}: font("${call.id}"): no ${badW.join("/")} face — available weights: ${meta.weights.join(", ")}.`);
856
+ if (call.italic) {
857
+ const badI = weights.filter((w) => !meta.italics.includes(w));
858
+ if (badI.length) {
859
+ throw new Error(`${entryPath}: font("${call.id}"): no italic ${badI.join("/")} face${meta.italics.length ? ` — italics exist for: ${meta.italics.join(", ")}` : " — the family ships no italics"}.`);
860
+ }
861
+ }
862
+ const callFaces = weights.flatMap((w) => call.italic ? [{ id: call.id, family: meta.family, weight: w, italic: false }, { id: call.id, family: meta.family, weight: w, italic: true }] : [{ id: call.id, family: meta.family, weight: w, italic: false }]);
863
+ return { family: meta.family, callFaces };
864
+ };
865
+ const pendingRegistry = new Map;
866
+ const work = [];
867
+ for (const { entry, calls } of scanned) {
868
+ const fw = { entry, calls: [], faceKeys: new Set };
869
+ for (const call of calls) {
870
+ const { family, callFaces } = resolveCall(norm(entry.path), call);
871
+ fw.calls.push({ call, family });
872
+ for (const cf of callFaces) {
873
+ const key = faceKey(cf.family, cf.weight, cf.italic);
874
+ fw.faceKeys.add(key);
875
+ if (faces.has(key))
876
+ continue;
877
+ if (cf.id === undefined) {
878
+ faces.set(key, { family: cf.family, weight: cf.weight, italic: cf.italic, source: { kind: "entry", path: call.filePath } });
879
+ } else {
880
+ pendingRegistry.set(key, cf);
881
+ faces.set(key, { family: cf.family, weight: cf.weight, italic: cf.italic, source: { kind: "cdn", urlBase: "" } });
882
+ }
883
+ }
884
+ }
885
+ work.push(fw);
886
+ }
887
+ for (const [key, face] of pendingRegistry) {
888
+ const ref = faces.get(key);
889
+ if (opts.mode === "cdn") {
890
+ ref.source = { kind: "cdn", urlBase: `${registryBase}/${face.id}/${face.id}-${face.weight}${face.italic ? "-italic" : ""}.` };
891
+ } else {
892
+ if (!opts.vendorFace)
893
+ throw new Error(`font("${face.id}"): registry fonts need vendoring in this compile context, but no vendor hook was provided.`);
894
+ const path = await opts.vendorFace({ ...face, fileName: faceFileName(face.id, face.weight, face.italic, "ttf") });
895
+ ref.source = { kind: "entry", path: norm(path) };
896
+ }
897
+ }
898
+ for (const fw of work) {
899
+ const entryPath = norm(fw.entry.path);
900
+ const dir = dirOf(entryPath);
901
+ const modBase = `.${baseOf(entryPath)}.__fonts.ts`;
902
+ const modPath = dir ? `${dir}/${modBase}` : modBase;
903
+ const idents = new Map;
904
+ for (const { family } of fw.calls) {
905
+ if (!idents.has(family))
906
+ idents.set(family, `__font_${idents.size}`);
907
+ }
908
+ let text = fw.entry.text;
909
+ for (const { call, family } of [...fw.calls].sort((a, b) => b.call.start - a.call.start)) {
910
+ text = text.slice(0, call.start) + idents.get(family) + text.slice(call.end);
911
+ }
912
+ text += `
913
+ import { ${[...idents.values()].join(", ")} } from ${JSON.stringify(`./${modBase}`)}`;
914
+ fw.entry.text = text;
915
+ const lines = [
916
+ "// Generated by the font() compile macro: registers this file's fonts. Imports hoist, so the",
917
+ "// faces are declared before the importing module's body runs. Hosts dedupe repeated faces."
918
+ ];
919
+ const faceImports = new Map;
920
+ const body = [];
921
+ for (const key of [...fw.faceKeys].sort()) {
922
+ const ref = faces.get(key);
923
+ let urlExpr;
924
+ if (ref.source.kind === "cdn") {
925
+ urlExpr = `${JSON.stringify(ref.source.urlBase)} + (_creatorUI.fontFormat ?? "ttf")`;
926
+ } else {
927
+ let ident = faceImports.get(ref.source.path);
928
+ if (!ident) {
929
+ ident = `__face_${faceImports.size}`;
930
+ faceImports.set(ref.source.path, ident);
931
+ lines.push(`import ${ident} from ${JSON.stringify(relSpecifier(dir, ref.source.path))}`);
932
+ }
933
+ urlExpr = ident;
934
+ }
935
+ const optionsExpr = ref.italic ? `{ weight: ${ref.weight}, style: "italic" }` : `{ weight: ${ref.weight} }`;
936
+ body.push(`_creatorUI.registerFont(${JSON.stringify(ref.family)}, ${urlExpr}, ${optionsExpr}, () => {}, () => {})`);
937
+ }
938
+ for (const [family, ident] of idents)
939
+ lines.push(`export const ${ident} = ${JSON.stringify(family)}`);
940
+ lines.push(...body);
941
+ entries.push({ path: modPath, type: "text", text: lines.join(`
942
+ `) + `
943
+ ` });
944
+ }
945
+ return [...faces.values()];
946
+ }, buildFontsHeaderValue = (faces, entryUrl) => faces.map((f2) => {
947
+ const tag = `${f2.family}@${f2.weight}${f2.italic ? "i" : ""}`;
948
+ if (f2.source.kind === "cdn")
949
+ return `${tag}=${f2.source.urlBase}{fmt}`;
950
+ const url = entryUrl?.(f2.source.path);
951
+ return url ? `${tag}=${url}` : null;
952
+ }).filter((s) => !!s).join(";");
953
+ var init_fontMacro = __esm(() => {
954
+ init_fontRegistry();
955
+ FONT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
956
+ CODE_FILE = /\.[tj]sx?$/;
957
+ DECLARES_FONT = /\b(?:function|const|let|var|class)\s+font\b|\bimport\s+(?:\{[^}]*\bfont\b[^}]*\}|font)\s+from\b/;
958
+ });
959
+
420
960
  // ../sdk/src/compile/index.ts
421
961
  var exports_compile = {};
422
962
  __export(exports_compile, {
423
963
  sceneEditorEntries: () => sceneEditorEntries,
964
+ scanFontCalls: () => scanFontCalls,
965
+ parseSfnt: () => parseSfnt,
424
966
  offsetSourceMap: () => offsetSourceMap,
425
967
  loadSceneHarness: () => loadSceneHarness,
426
968
  inlineSourceMapComment: () => inlineSourceMapComment,
969
+ fontRegistryBase: () => fontRegistryBase,
970
+ faceFileName: () => faceFileName,
427
971
  detectProjectKind: () => detectProjectKind,
428
972
  compileProject: () => compileProject,
429
973
  bundleProjectWithMap: () => bundleProjectWithMap,
430
974
  bundleProject: () => bundleProject,
431
975
  buildHeader: () => buildHeader,
976
+ buildFontsHeaderValue: () => buildFontsHeaderValue,
977
+ applyFontMacro: () => applyFontMacro,
432
978
  SCENE_HARNESS_DIR: () => SCENE_HARNESS_DIR,
433
- SCENE_ENTRY_PATH: () => SCENE_ENTRY_PATH
979
+ SCENE_ENTRY_PATH: () => SCENE_ENTRY_PATH,
980
+ FONT_REGISTRY: () => FONT_REGISTRY,
981
+ DEFAULT_FONT_REGISTRY_BASE: () => DEFAULT_FONT_REGISTRY_BASE
434
982
  });
435
983
  var init_compile = __esm(() => {
436
984
  init_bundler();
437
985
  init_compileProject();
438
986
  init_sceneEditor();
987
+ init_fontMacro();
988
+ init_fontRegistry();
439
989
  });
440
990
 
441
991
  // ../../node_modules/.bun/qrcode-terminal@0.12.0/node_modules/qrcode-terminal/vendor/QRCode/QRMode.js
@@ -1457,7 +2007,7 @@ var require_main = __commonJS((exports, module) => {
1457
2007
  // package.json
1458
2008
  var package_default = {
1459
2009
  name: "lecodes-cli",
1460
- version: "0.7.2",
2010
+ version: "0.8.1",
1461
2011
  description: "Clone, edit and push LeCodes projects from your machine.",
1462
2012
  type: "module",
1463
2013
  license: "MIT",
@@ -1507,16 +2057,22 @@ var package_default = {
1507
2057
  },
1508
2058
  devDependencies: {
1509
2059
  "lecodes-design": "workspace:*",
1510
- "lecodes-renderer": "^0.4.1",
2060
+ "lecodes-renderer": "workspace:*",
1511
2061
  "qrcode-terminal": "^0.12.0",
1512
2062
  "lecodes-3d-editor": "workspace:*",
1513
2063
  sdk: "workspace:*"
1514
2064
  }
1515
2065
  };
1516
2066
 
1517
- // src/commands/clone.ts
1518
- import { existsSync as existsSync5, mkdirSync as mkdirSync5, readdirSync } from "node:fs";
1519
- import { resolve } from "node:path";
2067
+ // src/commands/app.ts
2068
+ import { execFileSync } from "node:child_process";
2069
+ import { cpSync, existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync10, readdirSync as readdirSync4, rmSync, statSync as statSync2, writeFileSync as writeFileSync7 } from "node:fs";
2070
+ import { homedir as homedir2 } from "node:os";
2071
+ import { basename as basename4, dirname as dirname7, join as join11, resolve as resolve2 } from "node:path";
2072
+
2073
+ // src/manifest.ts
2074
+ import { dirname, join } from "node:path";
2075
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
1520
2076
 
1521
2077
  // src/util.ts
1522
2078
  import { createHash } from "node:crypto";
@@ -1621,17 +2177,145 @@ var flagStr = (args, ...keys) => {
1621
2177
  };
1622
2178
  var flagBool = (args, ...keys) => keys.some((key) => args.flags[key] === true || args.flags[key] === "true");
1623
2179
 
1624
- // src/api.ts
1625
- var errorMessage = (body, status) => {
1626
- if (typeof body === "string" && body)
1627
- return body;
1628
- if (body && typeof body === "object") {
1629
- const b = body;
1630
- if (typeof b.error === "string")
1631
- return b.error;
1632
- if (b.error && typeof b.error === "object") {
1633
- const first = Object.values(b.error)[0];
1634
- if (typeof first === "string")
2180
+ // src/manifest.ts
2181
+ var LECODES_DIR = ".lecodes";
2182
+ var manifestPath = (root) => join(root, LECODES_DIR, "manifest.json");
2183
+ var findProjectRoot = (start) => {
2184
+ let dir = start;
2185
+ while (true) {
2186
+ if (existsSync(manifestPath(dir)))
2187
+ return dir;
2188
+ const parent = dirname(dir);
2189
+ if (parent === dir)
2190
+ throw new CliError("Not inside a lecodes project (no .lecodes/manifest.json found).");
2191
+ dir = parent;
2192
+ }
2193
+ };
2194
+ var readManifest = (root) => {
2195
+ try {
2196
+ return JSON.parse(readFileSync(manifestPath(root), "utf8"));
2197
+ } catch {
2198
+ throw new CliError("Could not read .lecodes/manifest.json — is this a lecodes project?");
2199
+ }
2200
+ };
2201
+ var writeManifest = (root, manifest) => {
2202
+ mkdirSync(join(root, LECODES_DIR), { recursive: true });
2203
+ writeFileSync(manifestPath(root), JSON.stringify(manifest, null, 2));
2204
+ };
2205
+
2206
+ // src/ignore.ts
2207
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
2208
+ import { join as join2 } from "node:path";
2209
+ var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store"];
2210
+ var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
2211
+ # Only untracked files are affected — assets already in the project stay tracked
2212
+ # even if they match a pattern. Built-in defaults (node_modules, _*, *.log) always apply.
2213
+
2214
+ # Render / preview outputs (the default \`lecodes render --png\` target)
2215
+ screenshot.png
2216
+ shots/
2217
+
2218
+ # Image & other binary assets ARE pushed by default — they're real project resources.
2219
+ # Uncomment a pattern below only if you want to keep some untracked ones out of pushes.
2220
+ # *.png
2221
+
2222
+ # Local notes & dumps (uncomment to ignore)
2223
+ # *.json
2224
+ # notes.md
2225
+ `;
2226
+ var translate = (glob) => {
2227
+ let re = "";
2228
+ for (let i = 0;i < glob.length; i++) {
2229
+ const ch = glob[i];
2230
+ if (ch === "*") {
2231
+ if (glob[i + 1] === "*") {
2232
+ re += ".*";
2233
+ i++;
2234
+ if (glob[i + 1] === "/")
2235
+ i++;
2236
+ } else
2237
+ re += "[^/]*";
2238
+ } else if (ch === "?")
2239
+ re += "[^/]";
2240
+ else if (ch === "/")
2241
+ re += "/";
2242
+ else if ("\\^$.|+()[]{}".includes(ch))
2243
+ re += "\\" + ch;
2244
+ else
2245
+ re += ch;
2246
+ }
2247
+ return re;
2248
+ };
2249
+ var compile = (raw) => {
2250
+ let p = raw.trim();
2251
+ if (!p || p.startsWith("#"))
2252
+ return null;
2253
+ let negate = false;
2254
+ if (p.startsWith("!")) {
2255
+ negate = true;
2256
+ p = p.slice(1);
2257
+ }
2258
+ let dirOnly = false;
2259
+ if (p.endsWith("/")) {
2260
+ dirOnly = true;
2261
+ p = p.slice(0, -1);
2262
+ }
2263
+ const anchored = p.startsWith("/") || p.includes("/");
2264
+ if (p.startsWith("/"))
2265
+ p = p.slice(1);
2266
+ if (!p)
2267
+ return null;
2268
+ const body = translate(p);
2269
+ const re = anchored ? new RegExp("^/" + body + "(/.*)?$") : new RegExp("(^|/)" + body + "(/.*)?$");
2270
+ return { re, negate, dirOnly };
2271
+ };
2272
+ var buildMatcher = (rules) => (key, isDir) => {
2273
+ let ignored = false;
2274
+ for (const r of rules) {
2275
+ if (r.dirOnly && !isDir)
2276
+ continue;
2277
+ if (r.re.test(key))
2278
+ ignored = !r.negate;
2279
+ }
2280
+ return ignored;
2281
+ };
2282
+ var buildGlobMatcher = (patterns) => {
2283
+ const rules = patterns.map((p) => compile(p.trim().replace(/\/+$/, ""))).filter((r) => r !== null);
2284
+ const match = buildMatcher(rules);
2285
+ return (path) => match(path.startsWith("/") ? path : "/" + path, false);
2286
+ };
2287
+ var loadIgnore = (root) => {
2288
+ const lines = [...BUILTIN_PATTERNS];
2289
+ const file = join2(root, ".lecodesignore");
2290
+ if (existsSync2(file)) {
2291
+ try {
2292
+ lines.push(...readFileSync2(file, "utf8").split(/\r?\n/));
2293
+ } catch {}
2294
+ }
2295
+ const rules = lines.map(compile).filter((r) => r !== null);
2296
+ return buildMatcher(rules);
2297
+ };
2298
+ var writeDefaultIgnore = (root) => {
2299
+ const file = join2(root, ".lecodesignore");
2300
+ if (!existsSync2(file))
2301
+ writeFileSync2(file, DEFAULT_IGNORE_FILE);
2302
+ };
2303
+
2304
+ // src/commands/compile.ts
2305
+ import { writeFileSync as writeFileSync6 } from "node:fs";
2306
+ import { basename as basename3, resolve } from "node:path";
2307
+
2308
+ // src/api.ts
2309
+ var errorMessage = (body, status) => {
2310
+ if (typeof body === "string" && body)
2311
+ return body;
2312
+ if (body && typeof body === "object") {
2313
+ const b = body;
2314
+ if (typeof b.error === "string")
2315
+ return b.error;
2316
+ if (b.error && typeof b.error === "object") {
2317
+ const first = Object.values(b.error)[0];
2318
+ if (typeof first === "string")
1635
2319
  return first;
1636
2320
  if (first && typeof first === "object" && typeof first.message === "string") {
1637
2321
  return first.message;
@@ -1688,15 +2372,15 @@ var downloadBlob = async (apiUrl, src) => {
1688
2372
 
1689
2373
  // src/config.ts
1690
2374
  import { homedir } from "node:os";
1691
- import { join } from "node:path";
1692
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2375
+ import { join as join3 } from "node:path";
2376
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
1693
2377
  var DEFAULT_API = "https://le.codes";
1694
- var CONFIG_DIR = join(homedir(), ".lecodes");
1695
- var CONFIG_FILE = join(CONFIG_DIR, "config.json");
2378
+ var CONFIG_DIR = join3(homedir(), ".lecodes");
2379
+ var CONFIG_FILE = join3(CONFIG_DIR, "config.json");
1696
2380
  var loadConfig = () => {
1697
2381
  let stored = {};
1698
2382
  try {
1699
- stored = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
2383
+ stored = JSON.parse(readFileSync3(CONFIG_FILE, "utf8"));
1700
2384
  } catch {}
1701
2385
  return {
1702
2386
  apiUrl: process.env.LECODES_API ?? stored.apiUrl,
@@ -1704,8 +2388,8 @@ var loadConfig = () => {
1704
2388
  };
1705
2389
  };
1706
2390
  var saveConfig = (config) => {
1707
- mkdirSync(CONFIG_DIR, { recursive: true });
1708
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
2391
+ mkdirSync2(CONFIG_DIR, { recursive: true });
2392
+ writeFileSync3(CONFIG_FILE, JSON.stringify(config, null, 2));
1709
2393
  };
1710
2394
  var normalizeApiUrl = (url) => url.trim().replace(/\/+$/, "");
1711
2395
  var requireToken = (config) => {
@@ -1719,39 +2403,124 @@ var requireApiUrl = (config) => {
1719
2403
  return normalizeApiUrl(config.apiUrl);
1720
2404
  };
1721
2405
 
1722
- // src/manifest.ts
1723
- import { dirname, join as join2 } from "node:path";
1724
- import { existsSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
1725
- var LECODES_DIR = ".lecodes";
1726
- var manifestPath = (root) => join2(root, LECODES_DIR, "manifest.json");
1727
- var findProjectRoot = (start) => {
1728
- let dir = start;
1729
- while (true) {
1730
- if (existsSync(manifestPath(dir)))
1731
- return dir;
1732
- const parent = dirname(dir);
1733
- if (parent === dir)
1734
- throw new CliError("Not inside a lecodes project (no .lecodes/manifest.json found).");
1735
- dir = parent;
1736
- }
2406
+ // src/compile/collect.ts
2407
+ import { readFileSync as readFileSync5 } from "node:fs";
2408
+ import { basename } from "node:path";
2409
+
2410
+ // src/localFiles.ts
2411
+ import { readdirSync, readFileSync as readFileSync4 } from "node:fs";
2412
+ import { join as join4, relative } from "node:path";
2413
+ var SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"]);
2414
+ var SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"]);
2415
+ var TEXT_EXTS = new Set([
2416
+ "ts",
2417
+ "tsx",
2418
+ "js",
2419
+ "jsx",
2420
+ "mjs",
2421
+ "cjs",
2422
+ "json",
2423
+ "svg",
2424
+ "glsl",
2425
+ "vert",
2426
+ "frag",
2427
+ "txt",
2428
+ "md",
2429
+ "css",
2430
+ "html",
2431
+ "xml",
2432
+ "yml",
2433
+ "yaml",
2434
+ "csv"
2435
+ ]);
2436
+ var ext = (name) => {
2437
+ const i = name.lastIndexOf(".");
2438
+ return i < 0 ? "" : name.slice(i + 1).toLowerCase();
1737
2439
  };
1738
- var readManifest = (root) => {
1739
- try {
1740
- return JSON.parse(readFileSync2(manifestPath(root), "utf8"));
1741
- } catch {
1742
- throw new CliError("Could not read .lecodes/manifest.json — is this a lecodes project?");
1743
- }
2440
+ var classifyKind = (name) => {
2441
+ const e = ext(name);
2442
+ if (e === "mat")
2443
+ return "shader";
2444
+ if (e === "scene")
2445
+ return "scene";
2446
+ return TEXT_EXTS.has(e) ? "text" : "resource";
1744
2447
  };
1745
- var writeManifest = (root, manifest) => {
1746
- mkdirSync2(join2(root, LECODES_DIR), { recursive: true });
1747
- writeFileSync2(manifestPath(root), JSON.stringify(manifest, null, 2));
2448
+ var isTextKind = (kind) => kind !== "resource";
2449
+ var scanLocal = (root, opts = {}) => {
2450
+ const { ignore, tracked } = opts;
2451
+ const files = [];
2452
+ const dirs = [];
2453
+ const ignored = [];
2454
+ const isTracked = (key) => tracked?.has(key) ?? false;
2455
+ const hasTrackedUnder = (key) => {
2456
+ if (!tracked)
2457
+ return false;
2458
+ const prefix = key + "/";
2459
+ for (const t of tracked)
2460
+ if (t.startsWith(prefix))
2461
+ return true;
2462
+ return false;
2463
+ };
2464
+ const walk = (dir, parentIgnored) => {
2465
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2466
+ const abs = join4(dir, entry.name);
2467
+ const key = toKey(relative(root, abs));
2468
+ if (entry.isDirectory()) {
2469
+ if (SKIP_DIRS.has(entry.name))
2470
+ continue;
2471
+ const dirIgnored = parentIgnored || !!ignore && ignore(key, true);
2472
+ if (dirIgnored && !isTracked(key) && !hasTrackedUnder(key)) {
2473
+ ignored.push(key + "/");
2474
+ continue;
2475
+ }
2476
+ dirs.push(key);
2477
+ walk(abs, dirIgnored);
2478
+ } else if (entry.isFile()) {
2479
+ if (SKIP_FILES.has(entry.name))
2480
+ continue;
2481
+ const fileIgnored = parentIgnored || !!ignore && ignore(key, false);
2482
+ if (fileIgnored && !isTracked(key)) {
2483
+ ignored.push(key);
2484
+ continue;
2485
+ }
2486
+ files.push({ path: key, absPath: abs, sha: sha256(readFileSync4(abs)) });
2487
+ }
2488
+ }
2489
+ };
2490
+ walk(root, false);
2491
+ return { files, dirs, ignored };
2492
+ };
2493
+ var scanProject = (root, manifest) => scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest.files)) });
2494
+ var diffLocal = (manifest, scan) => {
2495
+ const added = [];
2496
+ const modified = [];
2497
+ const localByPath = new Map(scan.files.map((f) => [f.path, f]));
2498
+ for (const file of scan.files) {
2499
+ const known = manifest.files[file.path];
2500
+ if (!known)
2501
+ added.push(file);
2502
+ else if (known.sha !== file.sha)
2503
+ modified.push(file);
2504
+ }
2505
+ const deleted = Object.keys(manifest.files).filter((path) => !localByPath.has(path));
2506
+ const moves = new Map;
2507
+ const claimed = new Set;
2508
+ for (const file of added) {
2509
+ const from = deleted.find((path) => !claimed.has(path) && manifest.files[path].sha === file.sha);
2510
+ if (from) {
2511
+ moves.set(file.path, from);
2512
+ claimed.add(from);
2513
+ }
2514
+ }
2515
+ return { added, modified, deleted, moves };
1748
2516
  };
2517
+ var hasChanges = (diff) => diff.added.length + diff.modified.length + diff.deleted.length > 0;
1749
2518
 
1750
2519
  // src/project.ts
1751
- import { dirname as dirname2, join as join3 } from "node:path";
1752
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
2520
+ import { dirname as dirname2, join as join5 } from "node:path";
2521
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "node:fs";
1753
2522
  var kindOf = (type) => type === "file" ? "resource" : type;
1754
- var absOf = (root, key) => join3(root, ...key.replace(/^\//, "").split("/"));
2523
+ var absOf = (root, key) => join5(root, ...key.replace(/^\//, "").split("/"));
1755
2524
  var buildPaths = (assets) => {
1756
2525
  const pathById = new Map;
1757
2526
  for (const asset of assets) {
@@ -1778,11 +2547,11 @@ var materializeAssets = async (root, project, apiUrl, token, onProgress) => {
1778
2547
  if (!asset.file?.src)
1779
2548
  continue;
1780
2549
  const bytes = await downloadBlob(apiUrl, asset.file.src);
1781
- writeFileSync3(abs, bytes);
2550
+ writeFileSync4(abs, bytes);
1782
2551
  sha = sha256(bytes);
1783
2552
  } else {
1784
2553
  const text = asset.text ?? "";
1785
- writeFileSync3(abs, text);
2554
+ writeFileSync4(abs, text);
1786
2555
  sha = sha256(text);
1787
2556
  }
1788
2557
  files[key] = { id: asset.id, type: kindOf(asset.type), sha };
@@ -1791,479 +2560,1723 @@ var materializeAssets = async (root, project, apiUrl, token, onProgress) => {
1791
2560
  return files;
1792
2561
  };
1793
2562
 
1794
- // src/types.ts
1795
- import { dirname as dirname4, join as join5 } from "node:path";
1796
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
1797
-
1798
- // src/distRoot.ts
1799
- import { existsSync as existsSync2 } from "node:fs";
1800
- import { dirname as dirname3, join as join4 } from "node:path";
1801
- import { fileURLToPath } from "node:url";
1802
- var distRoot = (() => {
1803
- const candidates = [
1804
- join4(dirname3(fileURLToPath(import.meta.url)), ".."),
1805
- dirname3(process.execPath)
1806
- ];
1807
- for (const c2 of candidates)
1808
- if (existsSync2(join4(c2, "runtime")))
1809
- return c2;
1810
- return candidates[0];
1811
- })();
1812
-
1813
- // src/types.ts
1814
- var TSCONFIG = {
1815
- compilerOptions: {
1816
- target: "ES2020",
1817
- module: "ESNext",
1818
- moduleResolution: "bundler",
1819
- lib: ["ES2020"],
1820
- types: [],
1821
- allowJs: true,
1822
- checkJs: false,
1823
- strict: false,
1824
- noEmit: true,
1825
- skipLibCheck: true
1826
- },
1827
- include: [`${LECODES_DIR}/types/**/*.d.ts`, "**/*.ts", "**/*.js"],
1828
- exclude: ["node_modules"]
1829
- };
1830
- var writeTypeBundle = (root, bundle) => {
1831
- const typesDir = join5(root, LECODES_DIR, "types");
1832
- for (const [path, text] of Object.entries(bundle)) {
1833
- const dest = join5(typesDir, path);
1834
- mkdirSync4(dirname4(dest), { recursive: true });
1835
- writeFileSync4(dest, text);
2563
+ // src/compile/collect.ts
2564
+ var collectEntries = (root, manifest, project, opts = {}) => {
2565
+ const pathById = buildPaths(project.assets);
2566
+ const serverByPath = new Map;
2567
+ for (const asset of project.assets) {
2568
+ if (asset.type === "folder")
2569
+ continue;
2570
+ serverByPath.set(pathById.get(asset.id), { fileSrc: asset.file?.src ?? undefined, shaders: asset.shaders ?? [] });
1836
2571
  }
1837
- };
1838
- var materializeTypes = async (root, apiUrl) => {
1839
- writeTypeBundle(root, await getTypes(apiUrl));
1840
- };
1841
- var readLocalTypeBundle = () => {
1842
- const candidates = [
1843
- join5(distRoot, "runtime", "sdk-types.json"),
1844
- join5(distRoot, "..", "sdk", "dist", "types.json")
1845
- ];
1846
- for (const path of candidates) {
1847
- if (existsSync3(path)) {
1848
- try {
1849
- return JSON.parse(readFileSync3(path, "utf8"));
1850
- } catch {}
2572
+ const entries = [];
2573
+ const warnings = [];
2574
+ const scan = scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest.files)) });
2575
+ for (const file of scan.files) {
2576
+ const type = manifest.files[file.path]?.type ?? classifyKind(basename(file.path));
2577
+ if (type === "resource") {
2578
+ const src = serverByPath.get(file.path)?.fileSrc;
2579
+ if (!src && (opts.warnMissingUrl ?? true))
2580
+ warnings.push(`No server URL for ${file.path} — push it so its URL resolves.`);
2581
+ entries.push({ path: file.path, type, fileSrc: src, absPath: file.absPath });
2582
+ } else if (type === "shader") {
2583
+ const shaders = serverByPath.get(file.path)?.shaders;
2584
+ if (!shaders?.length)
2585
+ warnings.push(`No compiled shader for ${file.path} — push it to compile the shader.`);
2586
+ entries.push({ path: file.path, type, shaders });
2587
+ } else {
2588
+ entries.push({ path: file.path, type, text: readFileSync5(file.absPath, "utf8") });
1851
2589
  }
1852
2590
  }
1853
- return null;
1854
- };
1855
- var materializeTypesLocal = (root) => {
1856
- const bundle = readLocalTypeBundle();
1857
- if (!bundle)
1858
- return false;
1859
- writeTypeBundle(root, bundle);
1860
- return true;
2591
+ return { entries, warnings };
1861
2592
  };
1862
- var writeTsconfig = (root) => {
1863
- writeFileSync4(join5(root, "tsconfig.json"), JSON.stringify(TSCONFIG, null, 2));
2593
+
2594
+ // src/compile/collectLocal.ts
2595
+ import { readFileSync as readFileSync6 } from "node:fs";
2596
+ import { basename as basename2 } from "node:path";
2597
+ var collectLocalEntries = (root, manifest, assetBase) => {
2598
+ const entries = [];
2599
+ const warnings = [];
2600
+ const scan = scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest?.files ?? {})) });
2601
+ for (const file of scan.files) {
2602
+ const type = manifest?.files[file.path]?.type ?? classifyKind(basename2(file.path));
2603
+ if (type === "resource") {
2604
+ entries.push({ path: file.path, type, fileSrc: assetBase + encodeURI(file.path), absPath: file.absPath });
2605
+ } else if (type === "shader") {
2606
+ warnings.push(`${file.path}: shaders compile on the server — push the project once for shaders to work in dev mode.`);
2607
+ entries.push({ path: file.path, type, shaders: [] });
2608
+ } else {
2609
+ entries.push({ path: file.path, type, text: readFileSync6(file.absPath, "utf8") });
2610
+ }
2611
+ }
2612
+ return { entries, warnings };
1864
2613
  };
1865
2614
 
1866
- // src/ignore.ts
1867
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs";
1868
- import { join as join6 } from "node:path";
1869
- var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store"];
1870
- var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
1871
- # Only untracked files are affected — assets already in the project stay tracked
1872
- # even if they match a pattern. Built-in defaults (node_modules, _*, *.log) always apply.
2615
+ // src/compile/projectCompile.ts
2616
+ import { existsSync as existsSync6 } from "node:fs";
2617
+ import { createRequire as createRequire3 } from "node:module";
2618
+ import { dirname as dirname5, join as join9 } from "node:path";
1873
2619
 
1874
- # Render / preview outputs (the default \`lecodes render --png\` target)
1875
- screenshot.png
1876
- shots/
2620
+ // src/distRoot.ts
2621
+ import { existsSync as existsSync3 } from "node:fs";
2622
+ import { dirname as dirname3, join as join6 } from "node:path";
2623
+ import { fileURLToPath } from "node:url";
2624
+ var distRoot = (() => {
2625
+ const candidates = [
2626
+ join6(dirname3(fileURLToPath(import.meta.url)), ".."),
2627
+ dirname3(process.execPath)
2628
+ ];
2629
+ for (const c2 of candidates)
2630
+ if (existsSync3(join6(c2, "runtime")))
2631
+ return c2;
2632
+ return candidates[0];
2633
+ })();
1877
2634
 
1878
- # Image & other binary assets ARE pushed by default — they're real project resources.
1879
- # Uncomment a pattern below only if you want to keep some untracked ones out of pushes.
1880
- # *.png
2635
+ // src/compile/projectCompile.ts
2636
+ init_compile();
2637
+ var vendoredInject = join9(distRoot, "runtime", "sdk", "inject.ts");
2638
+ if (existsSync6(vendoredInject))
2639
+ process.env.LECODES_SDK_INJECT = vendoredInject;
2640
+ var resolveChiselBin2 = () => {
2641
+ if (process.env.LECODES_CHISEL_BIN)
2642
+ return process.env.LECODES_CHISEL_BIN;
2643
+ try {
2644
+ const fromPkg = createRequire3(import.meta.url)("@letary/chisel").binaryPath();
2645
+ if (fromPkg)
2646
+ return fromPkg;
2647
+ } catch {}
2648
+ const local = join9(dirname5(process.execPath), process.platform === "win32" ? "chisel.exe" : "chisel");
2649
+ return existsSync6(local) ? local : null;
2650
+ };
2651
+ var chiselBin = resolveChiselBin2();
2652
+ if (chiselBin)
2653
+ process.env.LECODES_CHISEL_BIN = chiselBin;
2654
+ var compileProject2 = (opts) => compileProject({
2655
+ entries: opts.entries.map((e) => ({ path: e.path, type: e.type, text: e.text, fileSrc: e.fileSrc, remote: e.remote, shaders: e.shaders })),
2656
+ name: opts.name,
2657
+ publicUrl: opts.publicUrl,
2658
+ entryOverride: opts.entryOverride,
2659
+ header: opts.header,
2660
+ fontsHeader: opts.fontsHeader,
2661
+ format: opts.format,
2662
+ localAssets: opts.localAssets,
2663
+ minify: opts.minify,
2664
+ sourcemap: opts.sourcemap,
2665
+ editor: opts.editor
2666
+ });
1881
2667
 
1882
- # Local notes & dumps (uncomment to ignore)
1883
- # *.json
1884
- # notes.md
1885
- `;
1886
- var translate = (glob) => {
1887
- let re = "";
1888
- for (let i = 0;i < glob.length; i++) {
1889
- const ch = glob[i];
1890
- if (ch === "*") {
1891
- if (glob[i + 1] === "*") {
1892
- re += ".*";
1893
- i++;
1894
- if (glob[i + 1] === "/")
1895
- i++;
1896
- } else
1897
- re += "[^/]*";
1898
- } else if (ch === "?")
1899
- re += "[^/]";
1900
- else if (ch === "/")
1901
- re += "/";
1902
- else if ("\\^$.|+()[]{}".includes(ch))
1903
- re += "\\" + ch;
1904
- else
1905
- re += ch;
1906
- }
1907
- return re;
2668
+ // src/compile/fonts.ts
2669
+ init_compile();
2670
+ import { createHash as createHash2 } from "node:crypto";
2671
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
2672
+ import { dirname as dirname6, join as join10 } from "node:path";
2673
+ var applyProjectFonts = async (entries, opts) => {
2674
+ const registry = (opts.registry ?? fontRegistryBase()).replace(/\/+$/, "");
2675
+ const byPath = () => new Map(entries.map((e) => [e.path.replace(/^\//, ""), e]));
2676
+ const faces = await applyFontMacro(entries, {
2677
+ mode: opts.mode,
2678
+ registryBase: registry,
2679
+ readBytes: (entryPath) => {
2680
+ const entry = byPath().get(entryPath);
2681
+ const abs = entry?.absPath ?? join10(opts.root, entryPath);
2682
+ try {
2683
+ return new Uint8Array(readFileSync9(abs));
2684
+ } catch {
2685
+ return null;
2686
+ }
2687
+ },
2688
+ vendorFace: opts.mode === "vendor" ? async (face) => {
2689
+ const vendorDir = opts.vendorDir ?? opts.root;
2690
+ const rel = `assets/fonts/${face.fileName}`;
2691
+ const abs = join10(vendorDir, rel);
2692
+ const entryPath = `${opts.vendorPrefix ?? ""}/${rel}`;
2693
+ if (!existsSync7(abs))
2694
+ await downloadFace(registry, vendorDir, face, rel, abs);
2695
+ if (!byPath().has(entryPath.replace(/^\//, ""))) {
2696
+ entries.push(opts.makeResourceEntry?.(entryPath, abs) ?? { path: entryPath, type: "resource", absPath: abs });
2697
+ }
2698
+ return entryPath;
2699
+ } : undefined
2700
+ });
2701
+ if (faces.length === 0)
2702
+ return;
2703
+ return buildFontsHeaderValue(faces, (path) => {
2704
+ if (!opts.publicUrl || !/^https?:/i.test(opts.publicUrl))
2705
+ return null;
2706
+ const fileSrc = byPath().get(path.replace(/^\//, ""))?.fileSrc;
2707
+ return fileSrc ? opts.publicUrl + fileSrc : null;
2708
+ }) || undefined;
1908
2709
  };
1909
- var compile = (raw) => {
1910
- let p = raw.trim();
1911
- if (!p || p.startsWith("#"))
1912
- return null;
1913
- let negate = false;
1914
- if (p.startsWith("!")) {
1915
- negate = true;
1916
- p = p.slice(1);
1917
- }
1918
- let dirOnly = false;
1919
- if (p.endsWith("/")) {
1920
- dirOnly = true;
1921
- p = p.slice(0, -1);
2710
+ var isHttp = (s) => /^https?:/i.test(s);
2711
+ var sha12 = (b) => createHash2("sha256").update(b).digest("hex").slice(0, 12);
2712
+ var registryHome = (registry) => registry.replace(/\/v\d+$/, "");
2713
+ var readRegistry = async (registry, rel) => {
2714
+ if (isHttp(registry)) {
2715
+ const res = await fetch(`${registry}/${rel}`);
2716
+ return res.ok ? new Uint8Array(await res.arrayBuffer()) : null;
1922
2717
  }
1923
- const anchored = p.startsWith("/") || p.includes("/");
1924
- if (p.startsWith("/"))
1925
- p = p.slice(1);
1926
- if (!p)
2718
+ try {
2719
+ return new Uint8Array(readFileSync9(join10(registry, rel)));
2720
+ } catch {
1927
2721
  return null;
1928
- const body = translate(p);
1929
- const re = anchored ? new RegExp("^/" + body + "(/.*)?$") : new RegExp("(^|/)" + body + "(/.*)?$");
1930
- return { re, negate, dirOnly };
2722
+ }
1931
2723
  };
1932
- var buildMatcher = (rules) => (key, isDir) => {
1933
- let ignored = false;
1934
- for (const r of rules) {
1935
- if (r.dirOnly && !isDir)
2724
+ var readLock = (path) => {
2725
+ try {
2726
+ const lock = JSON.parse(readFileSync9(path, "utf8"));
2727
+ if (lock && typeof lock === "object" && lock.faces && lock.families)
2728
+ return lock;
2729
+ } catch {}
2730
+ return null;
2731
+ };
2732
+ var serializeLock = (lock) => {
2733
+ const sortKeys = (rec) => Object.fromEntries(Object.keys(rec).sort().map((k) => [k, rec[k]]));
2734
+ return JSON.stringify({ registry: lock.registry, faces: sortKeys(lock.faces), families: sortKeys(lock.families) }, null, 2) + `
2735
+ `;
2736
+ };
2737
+ var renderNotices = (lock) => {
2738
+ const ids = [...new Set(Object.values(lock.faces).map((f2) => f2.id))].sort();
2739
+ const lines = [
2740
+ "# Font attribution & notices",
2741
+ "",
2742
+ "Fonts vendored from the font registry via `font(...)`. Keep this file in your repo / product.",
2743
+ ""
2744
+ ];
2745
+ for (const id of ids) {
2746
+ const l = lock.families[id];
2747
+ if (!l)
1936
2748
  continue;
1937
- if (r.re.test(key))
1938
- ignored = !r.negate;
2749
+ let note2 = `- **${l.title || id}** — ${l.spdx}${l.url ? ` — ${l.url}` : ""}`;
2750
+ if (l.attribution)
2751
+ note2 += `
2752
+ - **Attribution required:** ${l.attribution}`;
2753
+ lines.push(note2);
1939
2754
  }
1940
- return ignored;
2755
+ lines.push("");
2756
+ return lines.join(`
2757
+ `);
1941
2758
  };
1942
- var loadIgnore = (root) => {
1943
- const lines = [...BUILTIN_PATTERNS];
1944
- const file = join6(root, ".lecodesignore");
1945
- if (existsSync4(file)) {
1946
- try {
1947
- lines.push(...readFileSync4(file, "utf8").split(/\r?\n/));
1948
- } catch {}
2759
+ var writeIfChanged = (path, text) => {
2760
+ try {
2761
+ if (readFileSync9(path, "utf8") === text)
2762
+ return;
2763
+ } catch {}
2764
+ writeFileSync5(path, text);
2765
+ };
2766
+ var downloadFace = async (registry, vendorDir, face, rel, abs) => {
2767
+ const licRaw = await readRegistry(registry, "licenses.json");
2768
+ if (!licRaw)
2769
+ throw new Error(`font: can't reach the font registry (${registry}). Check your connection, or set LECODES_FONT_REGISTRY.`);
2770
+ const licenses = JSON.parse(new TextDecoder().decode(licRaw));
2771
+ const lic = licenses[face.id];
2772
+ if (!lic)
2773
+ throw new Error(`font("${face.id}"): the registry has no license entry for this family — it may not be published yet. Browse ${registryHome(registry)}.`);
2774
+ const bytes = await readRegistry(registry, `${face.id}/${face.fileName}`);
2775
+ if (!bytes)
2776
+ throw new Error(`font("${face.id}"): face "${face.fileName}" not found in the registry (${registryHome(registry)}).`);
2777
+ mkdirSync4(dirname6(abs), { recursive: true });
2778
+ writeFileSync5(abs, bytes);
2779
+ const lockPath = join10(vendorDir, "fonts.lock.json");
2780
+ const lock = readLock(lockPath) ?? { registry, faces: {}, families: {} };
2781
+ lock.registry = registry;
2782
+ lock.faces[`${face.id}@${face.weight}${face.italic ? "i" : ""}`] = { id: face.id, family: face.family, weight: face.weight, italic: face.italic, hash: sha12(bytes), spdx: lic.spdx, path: rel };
2783
+ lock.families[face.id] = lic;
2784
+ writeIfChanged(lockPath, serializeLock(lock));
2785
+ writeIfChanged(join10(vendorDir, "FONT-NOTICES.md"), renderNotices(lock));
2786
+ };
2787
+
2788
+ // src/commands/compile.ts
2789
+ var compile2 = async (args) => {
2790
+ await compileImpl(args);
2791
+ };
2792
+ var compileShell = (args, shell) => compileImpl(args, shell);
2793
+ var compileImpl = async (args, shell) => {
2794
+ let root;
2795
+ let manifest;
2796
+ try {
2797
+ root = findProjectRoot(process.cwd());
2798
+ manifest = readManifest(root);
2799
+ } catch {
2800
+ root = process.cwd();
2801
+ manifest = null;
1949
2802
  }
1950
- const rules = lines.map(compile).filter((r) => r !== null);
1951
- return buildMatcher(rules);
2803
+ let entries;
2804
+ let warnings;
2805
+ let name;
2806
+ let publicUrl;
2807
+ let localAssets = false;
2808
+ if (manifest) {
2809
+ const config = loadConfig();
2810
+ const apiUrl = requireApiUrl(config);
2811
+ const token = requireToken(config);
2812
+ note("Resolving project assets…");
2813
+ const project = await getProject(apiUrl, token, manifest.uuid);
2814
+ ({ entries, warnings } = collectEntries(root, manifest, project, { warnMissingUrl: !shell }));
2815
+ name = manifest.name;
2816
+ publicUrl = flagStr(args, "public-url") ?? apiUrl;
2817
+ localAssets = !!shell;
2818
+ if (shell && shell.remotePatterns.length > 0) {
2819
+ const isRemote = buildGlobMatcher(shell.remotePatterns);
2820
+ for (const e of entries) {
2821
+ if (e.type !== "resource" || !isRemote(e.path))
2822
+ continue;
2823
+ if (!e.fileSrc) {
2824
+ warnErr(`${e.path} matches resources.remote but has no server URL yet — bundling it (push, then re-sync).`);
2825
+ continue;
2826
+ }
2827
+ e.remote = true;
2828
+ }
2829
+ }
2830
+ } else {
2831
+ const publicUrlFlag = flagStr(args, "public-url");
2832
+ localAssets = shell ? true : publicUrlFlag === undefined;
2833
+ publicUrl = publicUrlFlag ?? "";
2834
+ ({ entries, warnings } = collectLocalEntries(root, null, ""));
2835
+ name = basename3(root);
2836
+ if (shell && shell.remotePatterns.length > 0) {
2837
+ warnErr("resources.remote needs a server-backed project (a streamed asset needs a server URL) — bundling everything.");
2838
+ }
2839
+ }
2840
+ const fontsHeader = await applyProjectFonts(entries, manifest && !shell ? { mode: "cdn", root, publicUrl } : {
2841
+ mode: "vendor",
2842
+ root,
2843
+ vendorDir: root,
2844
+ vendorPrefix: "",
2845
+ publicUrl: shell ? "" : publicUrl,
2846
+ makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: encodeURI(path), absPath })
2847
+ });
2848
+ note("Compiling…");
2849
+ const js = await compileProject2({
2850
+ entries,
2851
+ name,
2852
+ publicUrl,
2853
+ entryOverride: flagStr(args, "entry"),
2854
+ header: !flagBool(args, "no-header"),
2855
+ localAssets,
2856
+ fontsHeader,
2857
+ minify: flagBool(args, "no-minify") ? false : undefined,
2858
+ sourcemap: flagBool(args, "no-sourcemap", "no-source-map") ? false : undefined
2859
+ });
2860
+ for (const w of warnings)
2861
+ warnErr(w);
2862
+ const out = flagStr(args, "o", "out");
2863
+ if (out && isNullSink(out)) {
2864
+ note(`Discarded output (${out})`);
2865
+ } else if (out) {
2866
+ writeFileSync6(resolve(out), js);
2867
+ note(`Wrote ${out} (${js.length} bytes)`);
2868
+ } else {
2869
+ process.stdout.write(js);
2870
+ if (!js.endsWith(`
2871
+ `))
2872
+ process.stdout.write(`
2873
+ `);
2874
+ }
2875
+ return entries.filter((e) => e.type === "resource").map((e) => ({
2876
+ path: e.path.replace(/^\//, ""),
2877
+ name: e.path.split(/[\\/]/).pop() ?? e.path,
2878
+ absPath: e.absPath,
2879
+ remote: !!e.remote
2880
+ }));
1952
2881
  };
1953
- var writeDefaultIgnore = (root) => {
1954
- const file = join6(root, ".lecodesignore");
1955
- if (!existsSync4(file))
1956
- writeFileSync5(file, DEFAULT_IGNORE_FILE);
2882
+ var previewLegacy = async (args) => {
2883
+ warnErr("'lecodes preview' is now 'lecodes compile' — the old name still works.");
2884
+ await compile2(args);
2885
+ };
2886
+
2887
+ // src/commands/appTemplates.ts
2888
+ var pbxStr = (s) => s.replace(/[\\"]/g, (m) => "\\" + m);
2889
+ var pbxprojTemplate = ({ displayName, bundleId, teamId, version, buildNumber }) => {
2890
+ const target = `
2891
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
2892
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
2893
+ CODE_SIGN_STYLE = Automatic;
2894
+ CURRENT_PROJECT_VERSION = ${buildNumber};
2895
+ DEVELOPMENT_TEAM = "${pbxStr(teamId)}";
2896
+ GENERATE_INFOPLIST_FILE = YES;
2897
+ INFOPLIST_FILE = "App/Info.plist";
2898
+ INFOPLIST_KEY_CFBundleDisplayName = "${pbxStr(displayName)}";
2899
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
2900
+ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
2901
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
2902
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
2903
+ IPHONEOS_DEPLOYMENT_TARGET = 15.6;
2904
+ LD_RUNPATH_SEARCH_PATHS = (
2905
+ "$(inherited)",
2906
+ "@executable_path/Frameworks",
2907
+ );
2908
+ MARKETING_VERSION = ${version};
2909
+ PRODUCT_BUNDLE_IDENTIFIER = "${pbxStr(bundleId)}";
2910
+ PRODUCT_NAME = "$(TARGET_NAME)";
2911
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
2912
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
2913
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
2914
+ SWIFT_EMIT_LOC_STRINGS = YES;
2915
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
2916
+ SWIFT_VERSION = 5.0;
2917
+ TARGETED_DEVICE_FAMILY = "1,2";`;
2918
+ const project = (debug) => `
2919
+ ALWAYS_SEARCH_USER_PATHS = NO;
2920
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
2921
+ CLANG_ANALYZER_NONNULL = YES;
2922
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
2923
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
2924
+ CLANG_ENABLE_MODULES = YES;
2925
+ CLANG_ENABLE_OBJC_ARC = YES;
2926
+ CLANG_ENABLE_OBJC_WEAK = YES;
2927
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
2928
+ CLANG_WARN_BOOL_CONVERSION = YES;
2929
+ CLANG_WARN_COMMA = YES;
2930
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
2931
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
2932
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
2933
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
2934
+ CLANG_WARN_EMPTY_BODY = YES;
2935
+ CLANG_WARN_ENUM_CONVERSION = YES;
2936
+ CLANG_WARN_INFINITE_RECURSION = YES;
2937
+ CLANG_WARN_INT_CONVERSION = YES;
2938
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
2939
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
2940
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
2941
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
2942
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
2943
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
2944
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
2945
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
2946
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
2947
+ CLANG_WARN_UNREACHABLE_CODE = YES;
2948
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
2949
+ COPY_PHASE_STRIP = NO;
2950
+ DEBUG_INFORMATION_FORMAT = ${debug ? "dwarf" : '"dwarf-with-dsym"'};
2951
+ DEVELOPMENT_TEAM = "${pbxStr(teamId)}";${debug ? "" : `
2952
+ ENABLE_NS_ASSERTIONS = NO;`}
2953
+ ENABLE_STRICT_OBJC_MSGSEND = YES;${debug ? `
2954
+ ENABLE_TESTABILITY = YES;` : ""}
2955
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
2956
+ GCC_C_LANGUAGE_STANDARD = gnu17;${debug ? `
2957
+ GCC_DYNAMIC_NO_PIC = NO;` : ""}
2958
+ GCC_NO_COMMON_BLOCKS = YES;${debug ? `
2959
+ GCC_OPTIMIZATION_LEVEL = 0;
2960
+ GCC_PREPROCESSOR_DEFINITIONS = (
2961
+ "DEBUG=1",
2962
+ "$(inherited)",
2963
+ );` : ""}
2964
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
2965
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
2966
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
2967
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
2968
+ GCC_WARN_UNUSED_FUNCTION = YES;
2969
+ GCC_WARN_UNUSED_VARIABLE = YES;
2970
+ IPHONEOS_DEPLOYMENT_TARGET = 15.6;
2971
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
2972
+ MTL_ENABLE_DEBUG_INFO = ${debug ? "INCLUDE_SOURCE" : "NO"};
2973
+ MTL_FAST_MATH = YES;${debug ? `
2974
+ ONLY_ACTIVE_ARCH = YES;` : ""}
2975
+ SDKROOT = iphoneos;${debug ? `
2976
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
2977
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";` : `
2978
+ SWIFT_COMPILATION_MODE = wholemodule;
2979
+ VALIDATE_PRODUCT = YES;`}`;
2980
+ return `// !$*UTF8*$!
2981
+ {
2982
+ archiveVersion = 1;
2983
+ classes = {
2984
+ };
2985
+ objectVersion = 77;
2986
+ objects = {
2987
+
2988
+ /* Begin PBXBuildFile section */
2989
+ B73550812FEBAC710005B4CC /* LeCodesRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = B73550802FEBAC710005B4CC /* LeCodesRuntime */; };
2990
+ /* End PBXBuildFile section */
2991
+
2992
+ /* Begin PBXFileReference section */
2993
+ B73550672FEBAC0F0005B4CC /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
2994
+ /* End PBXFileReference section */
2995
+
2996
+ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
2997
+ B73550792FEBAC100005B4CC /* Exceptions for "App" folder in "App" target */ = {
2998
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
2999
+ membershipExceptions = (
3000
+ Info.plist,
3001
+ );
3002
+ target = B73550662FEBAC0F0005B4CC /* App */;
3003
+ };
3004
+ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
3005
+
3006
+ /* Begin PBXFileSystemSynchronizedRootGroup section */
3007
+ B73550692FEBAC0F0005B4CC /* App */ = {
3008
+ isa = PBXFileSystemSynchronizedRootGroup;
3009
+ exceptions = (
3010
+ B73550792FEBAC100005B4CC /* Exceptions for "App" folder in "App" target */,
3011
+ );
3012
+ path = App;
3013
+ sourceTree = "<group>";
3014
+ };
3015
+ /* End PBXFileSystemSynchronizedRootGroup section */
3016
+
3017
+ /* Begin PBXFrameworksBuildPhase section */
3018
+ B73550642FEBAC0F0005B4CC /* Frameworks */ = {
3019
+ isa = PBXFrameworksBuildPhase;
3020
+ buildActionMask = 2147483647;
3021
+ files = (
3022
+ B73550812FEBAC710005B4CC /* LeCodesRuntime in Frameworks */,
3023
+ );
3024
+ runOnlyForDeploymentPostprocessing = 0;
3025
+ };
3026
+ /* End PBXFrameworksBuildPhase section */
3027
+
3028
+ /* Begin PBXGroup section */
3029
+ B735505E2FEBAC0F0005B4CC = {
3030
+ isa = PBXGroup;
3031
+ children = (
3032
+ B73550692FEBAC0F0005B4CC /* App */,
3033
+ B73550682FEBAC0F0005B4CC /* Products */,
3034
+ );
3035
+ sourceTree = "<group>";
3036
+ };
3037
+ B73550682FEBAC0F0005B4CC /* Products */ = {
3038
+ isa = PBXGroup;
3039
+ children = (
3040
+ B73550672FEBAC0F0005B4CC /* App.app */,
3041
+ );
3042
+ name = Products;
3043
+ sourceTree = "<group>";
3044
+ };
3045
+ /* End PBXGroup section */
3046
+
3047
+ /* Begin PBXNativeTarget section */
3048
+ B73550662FEBAC0F0005B4CC /* App */ = {
3049
+ isa = PBXNativeTarget;
3050
+ buildConfigurationList = B735507A2FEBAC100005B4CC /* Build configuration list for PBXNativeTarget "App" */;
3051
+ buildPhases = (
3052
+ B73550632FEBAC0F0005B4CC /* Sources */,
3053
+ B73550642FEBAC0F0005B4CC /* Frameworks */,
3054
+ B73550652FEBAC0F0005B4CC /* Resources */,
3055
+ );
3056
+ buildRules = (
3057
+ );
3058
+ dependencies = (
3059
+ );
3060
+ fileSystemSynchronizedGroups = (
3061
+ B73550692FEBAC0F0005B4CC /* App */,
3062
+ );
3063
+ name = App;
3064
+ packageProductDependencies = (
3065
+ B73550802FEBAC710005B4CC /* LeCodesRuntime */,
3066
+ );
3067
+ productName = App;
3068
+ productReference = B73550672FEBAC0F0005B4CC /* App.app */;
3069
+ productType = "com.apple.product-type.application";
3070
+ };
3071
+ /* End PBXNativeTarget section */
3072
+
3073
+ /* Begin PBXProject section */
3074
+ B735505F2FEBAC0F0005B4CC /* Project object */ = {
3075
+ isa = PBXProject;
3076
+ attributes = {
3077
+ BuildIndependentTargetsInParallel = 1;
3078
+ LastSwiftUpdateCheck = 2650;
3079
+ LastUpgradeCheck = 2650;
3080
+ TargetAttributes = {
3081
+ B73550662FEBAC0F0005B4CC = {
3082
+ CreatedOnToolsVersion = 26.5;
3083
+ };
3084
+ };
3085
+ };
3086
+ buildConfigurationList = B73550622FEBAC0F0005B4CC /* Build configuration list for PBXProject "App" */;
3087
+ developmentRegion = en;
3088
+ hasScannedForEncodings = 0;
3089
+ knownRegions = (
3090
+ en,
3091
+ Base,
3092
+ );
3093
+ mainGroup = B735505E2FEBAC0F0005B4CC;
3094
+ minimizedProjectReferenceProxies = 1;
3095
+ packageReferences = (
3096
+ B73550822FEBAC720005B4CC /* XCLocalSwiftPackageReference "LeCodesRuntime" */,
3097
+ );
3098
+ preferredProjectObjectVersion = 77;
3099
+ productRefGroup = B73550682FEBAC0F0005B4CC /* Products */;
3100
+ projectDirPath = "";
3101
+ projectRoot = "";
3102
+ targets = (
3103
+ B73550662FEBAC0F0005B4CC /* App */,
3104
+ );
3105
+ };
3106
+ /* End PBXProject section */
3107
+
3108
+ /* Begin PBXResourcesBuildPhase section */
3109
+ B73550652FEBAC0F0005B4CC /* Resources */ = {
3110
+ isa = PBXResourcesBuildPhase;
3111
+ buildActionMask = 2147483647;
3112
+ files = (
3113
+ );
3114
+ runOnlyForDeploymentPostprocessing = 0;
3115
+ };
3116
+ /* End PBXResourcesBuildPhase section */
3117
+
3118
+ /* Begin PBXSourcesBuildPhase section */
3119
+ B73550632FEBAC0F0005B4CC /* Sources */ = {
3120
+ isa = PBXSourcesBuildPhase;
3121
+ buildActionMask = 2147483647;
3122
+ files = (
3123
+ );
3124
+ runOnlyForDeploymentPostprocessing = 0;
3125
+ };
3126
+ /* End PBXSourcesBuildPhase section */
3127
+
3128
+ /* Begin XCBuildConfiguration section */
3129
+ B735507B2FEBAC100005B4CC /* Debug */ = {
3130
+ isa = XCBuildConfiguration;
3131
+ buildSettings = {${target}
3132
+ };
3133
+ name = Debug;
3134
+ };
3135
+ B735507C2FEBAC100005B4CC /* Release */ = {
3136
+ isa = XCBuildConfiguration;
3137
+ buildSettings = {${target}
3138
+ };
3139
+ name = Release;
3140
+ };
3141
+ B735507D2FEBAC100005B4CC /* Debug */ = {
3142
+ isa = XCBuildConfiguration;
3143
+ buildSettings = {${project(true)}
3144
+ };
3145
+ name = Debug;
3146
+ };
3147
+ B735507E2FEBAC100005B4CC /* Release */ = {
3148
+ isa = XCBuildConfiguration;
3149
+ buildSettings = {${project(false)}
3150
+ };
3151
+ name = Release;
3152
+ };
3153
+ /* End XCBuildConfiguration section */
3154
+
3155
+ /* Begin XCConfigurationList section */
3156
+ B73550622FEBAC0F0005B4CC /* Build configuration list for PBXProject "App" */ = {
3157
+ isa = XCConfigurationList;
3158
+ buildConfigurations = (
3159
+ B735507D2FEBAC100005B4CC /* Debug */,
3160
+ B735507E2FEBAC100005B4CC /* Release */,
3161
+ );
3162
+ defaultConfigurationIsVisible = 0;
3163
+ defaultConfigurationName = Release;
3164
+ };
3165
+ B735507A2FEBAC100005B4CC /* Build configuration list for PBXNativeTarget "App" */ = {
3166
+ isa = XCConfigurationList;
3167
+ buildConfigurations = (
3168
+ B735507B2FEBAC100005B4CC /* Debug */,
3169
+ B735507C2FEBAC100005B4CC /* Release */,
3170
+ );
3171
+ defaultConfigurationIsVisible = 0;
3172
+ defaultConfigurationName = Release;
3173
+ };
3174
+ /* End XCConfigurationList section */
3175
+
3176
+ /* Begin XCLocalSwiftPackageReference section */
3177
+ B73550822FEBAC720005B4CC /* XCLocalSwiftPackageReference "LeCodesRuntime" */ = {
3178
+ isa = XCLocalSwiftPackageReference;
3179
+ relativePath = LeCodesRuntime;
3180
+ };
3181
+ /* End XCLocalSwiftPackageReference section */
3182
+
3183
+ /* Begin XCSwiftPackageProductDependency section */
3184
+ B73550802FEBAC710005B4CC /* LeCodesRuntime */ = {
3185
+ isa = XCSwiftPackageProductDependency;
3186
+ productName = LeCodesRuntime;
3187
+ };
3188
+ /* End XCSwiftPackageProductDependency section */
3189
+ };
3190
+ rootObject = B735505F2FEBAC0F0005B4CC /* Project object */;
3191
+ }
3192
+ `;
1957
3193
  };
3194
+ var SCHEME_XML = `<?xml version="1.0" encoding="UTF-8"?>
3195
+ <Scheme
3196
+ LastUpgradeVersion = "2650"
3197
+ version = "1.7">
3198
+ <BuildAction
3199
+ parallelizeBuildables = "YES"
3200
+ buildImplicitDependencies = "YES">
3201
+ <BuildActionEntries>
3202
+ <BuildActionEntry
3203
+ buildForTesting = "YES"
3204
+ buildForRunning = "YES"
3205
+ buildForProfiling = "YES"
3206
+ buildForArchiving = "YES"
3207
+ buildForAnalyzing = "YES">
3208
+ <BuildableReference
3209
+ BuildableIdentifier = "primary"
3210
+ BlueprintIdentifier = "B73550662FEBAC0F0005B4CC"
3211
+ BuildableName = "App.app"
3212
+ BlueprintName = "App"
3213
+ ReferencedContainer = "container:App.xcodeproj">
3214
+ </BuildableReference>
3215
+ </BuildActionEntry>
3216
+ </BuildActionEntries>
3217
+ </BuildAction>
3218
+ <TestAction
3219
+ buildConfiguration = "Debug"
3220
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
3221
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
3222
+ shouldUseLaunchSchemeArgsEnv = "YES">
3223
+ <Testables>
3224
+ </Testables>
3225
+ </TestAction>
3226
+ <LaunchAction
3227
+ buildConfiguration = "Debug"
3228
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
3229
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
3230
+ launchStyle = "0"
3231
+ useCustomWorkingDirectory = "NO"
3232
+ ignoresPersistentStateOnLaunch = "NO"
3233
+ debugDocumentVersioning = "YES"
3234
+ debugServiceExtension = "internal"
3235
+ allowLocationSimulation = "YES">
3236
+ <BuildableProductRunnable
3237
+ runnableDebuggingMode = "0">
3238
+ <BuildableReference
3239
+ BuildableIdentifier = "primary"
3240
+ BlueprintIdentifier = "B73550662FEBAC0F0005B4CC"
3241
+ BuildableName = "App.app"
3242
+ BlueprintName = "App"
3243
+ ReferencedContainer = "container:App.xcodeproj">
3244
+ </BuildableReference>
3245
+ </BuildableProductRunnable>
3246
+ </LaunchAction>
3247
+ <ProfileAction
3248
+ buildConfiguration = "Release"
3249
+ shouldUseLaunchSchemeArgsEnv = "YES"
3250
+ savedToolIdentifier = ""
3251
+ useCustomWorkingDirectory = "NO"
3252
+ debugDocumentVersioning = "YES">
3253
+ <BuildableProductRunnable
3254
+ runnableDebuggingMode = "0">
3255
+ <BuildableReference
3256
+ BuildableIdentifier = "primary"
3257
+ BlueprintIdentifier = "B73550662FEBAC0F0005B4CC"
3258
+ BuildableName = "App.app"
3259
+ BlueprintName = "App"
3260
+ ReferencedContainer = "container:App.xcodeproj">
3261
+ </BuildableReference>
3262
+ </BuildableProductRunnable>
3263
+ </ProfileAction>
3264
+ <AnalyzeAction
3265
+ buildConfiguration = "Debug">
3266
+ </AnalyzeAction>
3267
+ <ArchiveAction
3268
+ buildConfiguration = "Release"
3269
+ revealArchiveInOrganizer = "YES">
3270
+ </ArchiveAction>
3271
+ </Scheme>
3272
+ `;
3273
+ var APP_DELEGATE = `//
3274
+ // AppDelegate.swift
3275
+ // Generated by lecodes app init — yours to edit.
3276
+ //
3277
+
3278
+ import UIKit
3279
+
3280
+ @main
3281
+ class AppDelegate: UIResponder, UIApplicationDelegate {
3282
+
3283
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
3284
+ return true
3285
+ }
3286
+
3287
+ // MARK: UISceneSession Lifecycle
3288
+
3289
+ func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
3290
+ return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
3291
+ }
3292
+ }
3293
+ `;
3294
+ var SCENE_DELEGATE = `//
3295
+ // SceneDelegate.swift
3296
+ // Generated by lecodes app init — yours to edit.
3297
+ //
3298
+
3299
+ import UIKit
3300
+
3301
+ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
3302
+
3303
+ var window: UIWindow?
3304
+
3305
+ func scene(
3306
+ _ scene: UIScene,
3307
+ willConnectTo session: UISceneSession,
3308
+ options connectionOptions: UIScene.ConnectionOptions
3309
+ ) {
3310
+ guard let windowScene = (scene as? UIWindowScene) else { return }
3311
+
3312
+ let window = UIWindow(windowScene: windowScene)
3313
+ window.rootViewController = ViewController()
3314
+ self.window = window
3315
+ window.makeKeyAndVisible()
3316
+ }
3317
+ }
3318
+ `;
3319
+ var VIEW_CONTROLLER = `//
3320
+ // ViewController.swift
3321
+ // Generated by lecodes app init — yours to edit.
3322
+ //
3323
+ // Runs the bundled app.js (produced by \`lecodes app sync\`) offline: everything the
3324
+ // script needs is provided by the SDK core (_creatorUI / _creatorUtils / console)
3325
+ // plus the plugins registered below via LeCodesRuntime.
3326
+ //
3327
+
3328
+ import UIKit
3329
+ import LeCodesRuntime
3330
+
3331
+ class ViewController: LeCodesViewController {
3332
+
3333
+ override func viewDidLoad() {
3334
+ super.viewDidLoad()
3335
+
3336
+ let lang = Locale.preferredLanguages.first.map { String($0.prefix(2)) } ?? "en"
3337
+ engine.setLanguage(lang)
3338
+
3339
+ // \`// preload:\` assets (images, fonts) resolve out of the app bundle.
3340
+ engine.setFetchLocal { path in
3341
+ let ns = path as NSString
3342
+ let ext = ns.pathExtension
3343
+ let name = ns.deletingPathExtension
3344
+ guard let url = Bundle.main.url(forResource: name, withExtension: ext.isEmpty ? nil : ext) else {
3345
+ return nil
3346
+ }
3347
+ return try? Data(contentsOf: url)
3348
+ }
1958
3349
 
1959
- // src/commands/clone.ts
1960
- var parseUuid = (input) => {
1961
- if (!input)
1962
- throw new CliError("Usage: lecodes clone <project-uuid|url> [directory]");
1963
- if (!input.includes("/"))
1964
- return input;
1965
- const clean = input.split(/[?#]/)[0].replace(/\/+$/, "");
1966
- const match = clean.match(/\/(?:projects|view)\/([^/]+)/);
1967
- return match ? match[1] : clean.slice(clean.lastIndexOf("/") + 1);
3350
+ registerBundledPlugins(in: engine)
3351
+
3352
+ guard let url = Bundle.main.url(forResource: "app", withExtension: "js"),
3353
+ let code = try? String(contentsOf: url, encoding: .utf8) else {
3354
+ print("LeCodes: bundled app.js is missing — run \`lecodes app sync\`")
3355
+ return
3356
+ }
3357
+ engine.run(code)
3358
+ }
3359
+ }
3360
+ `;
3361
+ var infoPlistTemplate = (pluginKeys) => {
3362
+ const keys = Object.keys(pluginKeys).sort();
3363
+ const pluginBlock = keys.length === 0 ? "" : `
3364
+ <!-- lecodes:plugins — merged from installed plugin manifests by \`lecodes app sync\` -->
3365
+ ${keys.map((k) => ` <key>${k}</key>
3366
+ <string>${pluginKeys[k]}</string>`).join(`
3367
+ `)}`;
3368
+ return `<?xml version="1.0" encoding="UTF-8"?>
3369
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3370
+ <plist version="1.0">
3371
+ <dict>
3372
+ <key>UIApplicationSceneManifest</key>
3373
+ <dict>
3374
+ <key>UIApplicationSupportsMultipleScenes</key>
3375
+ <false/>
3376
+ <key>UISceneConfigurations</key>
3377
+ <dict>
3378
+ <key>UIWindowSceneSessionRoleApplication</key>
3379
+ <array>
3380
+ <dict>
3381
+ <key>UISceneConfigurationName</key>
3382
+ <string>Default Configuration</string>
3383
+ <key>UISceneDelegateClassName</key>
3384
+ <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
3385
+ </dict>
3386
+ </array>
3387
+ </dict>
3388
+ </dict>${pluginBlock}
3389
+ </dict>
3390
+ </plist>
3391
+ `;
1968
3392
  };
1969
- var slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1970
- var clone = async (args) => {
1971
- const config = loadConfig();
1972
- const apiUrl = requireApiUrl(config);
1973
- const token = requireToken(config);
1974
- const uuid = parseUuid(args._[0]);
1975
- const project = await getProject(apiUrl, token, uuid);
1976
- const dir = args._[1] ?? slug(project.name);
1977
- const root = resolve(dir);
1978
- if (existsSync5(root) && readdirSync(root).length > 0) {
1979
- throw new CliError(`Directory "${dir}" already exists and is not empty.`);
3393
+ var LAUNCH_STORYBOARD = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
3394
+ <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
3395
+ <dependencies>
3396
+ <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
3397
+ <capability name="Safe area layout guides" minToolsVersion="9.0"/>
3398
+ <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
3399
+ </dependencies>
3400
+ <scenes>
3401
+ <!--View Controller-->
3402
+ <scene sceneID="EHf-IW-A2E">
3403
+ <objects>
3404
+ <viewController id="01J-lp-oVM" sceneMemberID="viewController">
3405
+ <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
3406
+ <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
3407
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
3408
+ <color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
3409
+ <viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
3410
+ </view>
3411
+ </viewController>
3412
+ <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
3413
+ </objects>
3414
+ <point key="canvasLocation" x="53" y="375"/>
3415
+ </scene>
3416
+ </scenes>
3417
+ </document>
3418
+ `;
3419
+ var ASSETS_ROOT_JSON = `{
3420
+ "info" : {
3421
+ "author" : "xcode",
3422
+ "version" : 1
1980
3423
  }
1981
- mkdirSync5(root, { recursive: true });
1982
- info(c.dim(`Fetching project ${c.bold(project.name)}…`));
1983
- const files = await materializeAssets(root, project, apiUrl, token, progress);
1984
- endProgress();
1985
- if (!flagBool(args, "no-types")) {
1986
- info(c.dim("Fetching type definitions…"));
1987
- await materializeTypes(root, apiUrl);
1988
- writeTsconfig(root);
3424
+ }
3425
+ `;
3426
+ var ACCENT_COLOR_JSON = `{
3427
+ "colors" : [
3428
+ {
3429
+ "idiom" : "universal"
3430
+ }
3431
+ ],
3432
+ "info" : {
3433
+ "author" : "xcode",
3434
+ "version" : 1
1989
3435
  }
1990
- writeDefaultIgnore(root);
1991
- const commits = await getCommits(apiUrl, token, uuid);
1992
- writeManifest(root, { uuid, apiUrl, name: project.name, baseHeadId: commits.headId, files });
1993
- success(`Cloned ${c.bold(project.name)} into ${c.bold(dir)}/ (${Object.keys(files).length} files)`);
1994
- info(c.dim(` cd ${dir} — edit, then \`lecodes push -m "your message"\``));
3436
+ }
3437
+ `;
3438
+ var APP_ICON_JSON = `{
3439
+ "images" : [
3440
+ {
3441
+ "idiom" : "universal",
3442
+ "platform" : "ios",
3443
+ "size" : "1024x1024"
3444
+ },
3445
+ {
3446
+ "appearances" : [
3447
+ {
3448
+ "appearance" : "luminosity",
3449
+ "value" : "dark"
3450
+ }
3451
+ ],
3452
+ "idiom" : "universal",
3453
+ "platform" : "ios",
3454
+ "size" : "1024x1024"
3455
+ },
3456
+ {
3457
+ "appearances" : [
3458
+ {
3459
+ "appearance" : "luminosity",
3460
+ "value" : "tinted"
3461
+ }
3462
+ ],
3463
+ "idiom" : "universal",
3464
+ "platform" : "ios",
3465
+ "size" : "1024x1024"
3466
+ }
3467
+ ],
3468
+ "info" : {
3469
+ "author" : "xcode",
3470
+ "version" : 1
3471
+ }
3472
+ }
3473
+ `;
3474
+ var runtimePackageTemplate = (sdkVersion, variant, plugins) => {
3475
+ const product = `LeCodesSDK-${variant}`;
3476
+ const pluginTargets = plugins.map((p) => `
3477
+ // plugin: ${p.id} (vendored — see plugins.lock.json)
3478
+ .target(
3479
+ name: "${p.module}",
3480
+ dependencies: [.product(name: "${product}", package: "lecodes-ios-sdk")],
3481
+ path: "Sources/${p.module}"
3482
+ ),`).join("");
3483
+ const runtimeDeps = [`.product(name: "${product}", package: "lecodes-ios-sdk")`, ...plugins.map((p) => `"${p.module}"`)];
3484
+ return `// swift-tools-version:5.9
3485
+ //
3486
+ // GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
3487
+ // Owns the app's native dependency graph: the LeCodesSDK variant (auto-picked from what
3488
+ // the compiled bundle uses, or forced via app.json ios.variant) and one vendored target
3489
+ // per installed plugin (see plugins.lock.json).
3490
+
3491
+ import PackageDescription
3492
+
3493
+ let package = Package(
3494
+ name: "LeCodesRuntime",
3495
+ platforms: [.iOS(.v15)],
3496
+ products: [
3497
+ .library(name: "LeCodesRuntime", targets: ["LeCodesRuntime"]),
3498
+ ],
3499
+ dependencies: [
3500
+ .package(url: "https://github.com/letary/lecodes-ios-sdk.git", from: "${sdkVersion}"),
3501
+ ],
3502
+ targets: [
3503
+ .target(
3504
+ name: "LeCodesRuntime",
3505
+ dependencies: [
3506
+ ${runtimeDeps.join(`,
3507
+ `)},
3508
+ ],
3509
+ path: "Sources/LeCodesRuntime"
3510
+ ),${pluginTargets}
3511
+ ]
3512
+ )
3513
+ `;
3514
+ };
3515
+ var runtimeSwiftTemplate = (plugins) => {
3516
+ const imports = plugins.map((p) => `import ${p.module}
3517
+ `).join("");
3518
+ const calls = plugins.length === 0 ? `
3519
+ ` : plugins.map((p) => ` ${p.register}.register(in: engine)
3520
+ `).join("");
3521
+ return `//
3522
+ // LeCodesRuntime.swift
3523
+ // GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
3524
+ //
3525
+ // Re-exports the SDK (so app code needs only \`import LeCodesRuntime\`) and registers
3526
+ // every plugin installed in app.json.
3527
+ //
3528
+
3529
+ @_exported import LeCodesSDK
3530
+ ${imports}
3531
+ public func registerBundledPlugins(in engine: LeCodesEngine) {
3532
+ ${calls}}
3533
+ `;
1995
3534
  };
3535
+ var IOS_README = `# ios/ — native Xcode shell
1996
3536
 
1997
- // src/commands/compile.ts
1998
- import { writeFileSync as writeFileSync6 } from "node:fs";
1999
- import { basename as basename3, resolve as resolve2 } from "node:path";
3537
+ Generated by \`lecodes app init ios\` for the LeCodes project one folder up. The app runs the
3538
+ compiled bundle in \`App/Resources/app.js\` — fully offline, nothing fetched at launch. This
3539
+ folder is ignored by the platform (\`ios/\` in \`.lecodesignore\`) and is never pushed to LeCodes.
2000
3540
 
2001
- // src/compile/collect.ts
2002
- import { readFileSync as readFileSync6 } from "node:fs";
2003
- import { basename } from "node:path";
3541
+ ## Daily workflow
2004
3542
 
2005
- // src/localFiles.ts
2006
- import { readdirSync as readdirSync2, readFileSync as readFileSync5 } from "node:fs";
2007
- import { join as join7, relative } from "node:path";
2008
- var SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"]);
2009
- var SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"]);
2010
- var TEXT_EXTS = new Set([
2011
- "ts",
2012
- "tsx",
2013
- "js",
2014
- "jsx",
2015
- "mjs",
2016
- "cjs",
2017
- "json",
2018
- "svg",
2019
- "glsl",
2020
- "vert",
2021
- "frag",
2022
- "txt",
2023
- "md",
2024
- "css",
2025
- "html",
2026
- "xml",
2027
- "yml",
2028
- "yaml",
2029
- "csv"
2030
- ]);
2031
- var ext = (name) => {
2032
- const i = name.lastIndexOf(".");
2033
- return i < 0 ? "" : name.slice(i + 1).toLowerCase();
3543
+ Edit the app's code at the project root (\`main.ts\` …), then:
3544
+
3545
+ \`\`\`
3546
+ lecodes app sync # recompile app.js + assets into the shell, vendor plugins
3547
+ lecodes app open # open App.xcodeproj in Xcode — build & run from there
3548
+ \`\`\`
3549
+
3550
+ \`lecodes app doctor\` preflights builds; \`lecodes app build ios\` archives and exports an
3551
+ .ipa; \`lecodes app upload ios\` sends the archive to App Store Connect.
3552
+
3553
+ ## Who owns what
3554
+
3555
+ Yours — \`lecodes app sync\` never touches these:
3556
+
3557
+ - \`App/*.swift\` (AppDelegate, SceneDelegate, ViewController) — customize freely, add files.
3558
+ App-local native code goes here too: add a Swift file that calls
3559
+ \`engine.registerCallback\` / \`registerService\`, and invoke it from ViewController.
3560
+ - \`App/Assets.xcassets/\` — drop a 1024×1024 PNG into AppIcon.appiconset/ for the app icon.
3561
+ - \`App/Base.lproj/LaunchScreen.storyboard\`
3562
+
3563
+ CLI-owned — regenerated on every sync, hand edits are overwritten:
3564
+
3565
+ - \`App/Resources/\` — the compiled app.js + every asset it references (fonts included), so
3566
+ the app runs fully offline. Globs in app.json \`"resources": {"remote": [...]}\` opt heavy
3567
+ assets (videos, big models) back into streaming from the server instead.
3568
+ - \`App/Info.plist\` — template + the installed plugins' usage keys
3569
+ - \`LeCodesRuntime/\` — local Swift package: the LeCodesSDK dependency, vendored plugin
3570
+ sources (Sources/<Module>/), and the registerBundledPlugins entry point
3571
+ - the version values inside \`App.xcodeproj/project.pbxproj\` (from app.json)
3572
+ - this README
3573
+
3574
+ ## SDK variant
3575
+
3576
+ Sync links the smallest SDK product that covers what the compiled bundle actually uses:
3577
+ \`core\` (UI only), \`2d\` (+ 2D engine with physics), \`3d\` (+ 3D/AR engine with physics,
3578
+ ~17 MB heavier), or \`full\` (everything). Adding your first 3D scene or 2D game to the
3579
+ project switches it automatically on the next sync; set \`ios.variant\` in \`../app.json\`
3580
+ to pin one instead.
3581
+
3582
+ ## Config
3583
+
3584
+ Identity lives in \`../app.json\` (name, version, plugins, ios.bundleId / teamId / variant /
3585
+ buildNumber) — edit it there, then re-run \`lecodes app sync\`. Plugins are managed with
3586
+ \`lecodes app plugin add <id | github:owner/repo[@tag]>\` and pinned in \`../plugins.lock.json\`.
3587
+ `;
3588
+
3589
+ // src/commands/app.ts
3590
+ var SDK_PACKAGE_VERSION = "1.1.0";
3591
+ var SDK_VARIANTS = ["core", "2d", "3d", "full"];
3592
+ var FIRST_PARTY_PLUGINS = {
3593
+ camera: "letary/lecodes-plugins",
3594
+ "qr-scanner": "letary/lecodes-plugins"
2034
3595
  };
2035
- var classifyKind = (name) => {
2036
- const e = ext(name);
2037
- if (e === "mat")
2038
- return "shader";
2039
- if (e === "scene")
2040
- return "scene";
2041
- return TEXT_EXTS.has(e) ? "text" : "resource";
3596
+ var readJson = (path) => {
3597
+ if (!existsSync8(path))
3598
+ return null;
3599
+ try {
3600
+ return JSON.parse(readFileSync10(path, "utf8"));
3601
+ } catch {
3602
+ throw new CliError(`Invalid JSON in ${path}`);
3603
+ }
2042
3604
  };
2043
- var isTextKind = (kind) => kind !== "resource";
2044
- var scanLocal = (root, opts = {}) => {
2045
- const { ignore, tracked } = opts;
2046
- const files = [];
2047
- const dirs = [];
2048
- const ignored = [];
2049
- const isTracked = (key) => tracked?.has(key) ?? false;
2050
- const hasTrackedUnder = (key) => {
2051
- if (!tracked)
2052
- return false;
2053
- const prefix = key + "/";
2054
- for (const t of tracked)
2055
- if (t.startsWith(prefix))
2056
- return true;
2057
- return false;
2058
- };
2059
- const walk = (dir, parentIgnored) => {
2060
- for (const entry of readdirSync2(dir, { withFileTypes: true })) {
2061
- const abs = join7(dir, entry.name);
2062
- const key = toKey(relative(root, abs));
2063
- if (entry.isDirectory()) {
2064
- if (SKIP_DIRS.has(entry.name))
2065
- continue;
2066
- const dirIgnored = parentIgnored || !!ignore && ignore(key, true);
2067
- if (dirIgnored && !isTracked(key) && !hasTrackedUnder(key)) {
2068
- ignored.push(key + "/");
2069
- continue;
2070
- }
2071
- dirs.push(key);
2072
- walk(abs, dirIgnored);
2073
- } else if (entry.isFile()) {
2074
- if (SKIP_FILES.has(entry.name))
2075
- continue;
2076
- const fileIgnored = parentIgnored || !!ignore && ignore(key, false);
2077
- if (fileIgnored && !isTracked(key)) {
2078
- ignored.push(key);
2079
- continue;
2080
- }
2081
- files.push({ path: key, absPath: abs, sha: sha256(readFileSync5(abs)) });
2082
- }
3605
+ var writeJson = (path, value) => writeFileSync7(path, JSON.stringify(value, null, 2) + `
3606
+ `);
3607
+ var projectRoot = () => {
3608
+ try {
3609
+ return findProjectRoot(process.cwd());
3610
+ } catch {
3611
+ return process.cwd();
3612
+ }
3613
+ };
3614
+ var git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
3615
+ var ensureRepo = (repo, wantTag, lock) => {
3616
+ const override = process.env.LECODES_PLUGINS_DIR;
3617
+ if (override) {
3618
+ note(`Using local plugin checkout ${override} (LECODES_PLUGINS_DIR)`);
3619
+ return { dir: resolve2(override), tag: "local", commit: "local" };
3620
+ }
3621
+ const cache = join11(homedir2(), ".lecodes", "plugins", repo.replace("/", "__"));
3622
+ const url = `https://github.com/${repo}.git`;
3623
+ if (!existsSync8(join11(cache, ".git"))) {
3624
+ mkdirSync5(dirname7(cache), { recursive: true });
3625
+ note(`Cloning ${url}…`);
3626
+ git(dirname7(cache), "clone", "--quiet", url, cache);
3627
+ }
3628
+ let tag = lock?.tag ?? wantTag;
3629
+ let commit = lock?.commit;
3630
+ if (commit) {
3631
+ try {
3632
+ git(cache, "cat-file", "-e", `${commit}^{commit}`);
3633
+ } catch {
3634
+ git(cache, "fetch", "--quiet", "--tags", "origin");
3635
+ }
3636
+ } else {
3637
+ git(cache, "fetch", "--quiet", "--tags", "origin");
3638
+ if (!tag) {
3639
+ tag = git(cache, "tag", "--list", "v*", "--sort=-version:refname").split(`
3640
+ `)[0] ?? "";
3641
+ if (!tag)
3642
+ throw new CliError(`${repo} has no v* version tags to install from`);
3643
+ }
3644
+ commit = git(cache, "rev-list", "-n", "1", tag);
3645
+ }
3646
+ git(cache, "checkout", "--quiet", "--detach", commit);
3647
+ return { dir: cache, tag: tag ?? "unknown", commit };
3648
+ };
3649
+ var resolvePlugin = (id, spec, lock) => {
3650
+ let repo;
3651
+ let subdir;
3652
+ let wantTag = null;
3653
+ if (spec.startsWith("github:")) {
3654
+ const [repoPart, tagPart] = spec.slice("github:".length).split("@");
3655
+ repo = repoPart;
3656
+ wantTag = tagPart ?? null;
3657
+ subdir = "";
3658
+ } else {
3659
+ repo = FIRST_PARTY_PLUGINS[id];
3660
+ if (!repo) {
3661
+ throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use a github:owner/repo spec.`);
3662
+ }
3663
+ subdir = `plugins/${id}`;
3664
+ }
3665
+ const checkout = ensureRepo(repo, wantTag, lock?.repo === repo ? lock : undefined);
3666
+ const dir = subdir ? join11(checkout.dir, subdir) : checkout.dir;
3667
+ const manifest = readJson(join11(dir, "lecodes-plugin.json"));
3668
+ if (!manifest)
3669
+ throw new CliError(`Plugin "${id}": no lecodes-plugin.json in ${dir}`);
3670
+ if (manifest.id !== id)
3671
+ throw new CliError(`Plugin "${id}": manifest declares id "${manifest.id}"`);
3672
+ if (!manifest.ios)
3673
+ throw new CliError(`Plugin "${id}" does not support iOS (manifest "ios" is null)`);
3674
+ if (spec.startsWith("github:")) {
3675
+ const names = [...manifest.provides?.views ?? [], ...manifest.provides?.services ?? []];
3676
+ for (const name of names) {
3677
+ if (!name.includes("."))
3678
+ warn(`Plugin "${id}" provides bare channel name "${name}" — third-party names should be vendor-prefixed (e.g. "acme.${name}").`);
2083
3679
  }
3680
+ }
3681
+ return {
3682
+ id,
3683
+ dir,
3684
+ manifest,
3685
+ lock: { repo, tag: checkout.tag, commit: checkout.commit, version: manifest.version ?? "unknown" }
2084
3686
  };
2085
- walk(root, false);
2086
- return { files, dirs, ignored };
2087
3687
  };
2088
- var scanProject = (root, manifest) => scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest.files)) });
2089
- var diffLocal = (manifest, scan) => {
2090
- const added = [];
2091
- const modified = [];
2092
- const localByPath = new Map(scan.files.map((f) => [f.path, f]));
2093
- for (const file of scan.files) {
2094
- const known = manifest.files[file.path];
2095
- if (!known)
2096
- added.push(file);
2097
- else if (known.sha !== file.sha)
2098
- modified.push(file);
2099
- }
2100
- const deleted = Object.keys(manifest.files).filter((path) => !localByPath.has(path));
2101
- const moves = new Map;
2102
- const claimed = new Set;
2103
- for (const file of added) {
2104
- const from = deleted.find((path) => !claimed.has(path) && manifest.files[path].sha === file.sha);
2105
- if (from) {
2106
- moves.set(file.path, from);
2107
- claimed.add(from);
3688
+ var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "app";
3689
+ var appInit = async (args) => {
3690
+ const platform = args._[1];
3691
+ if (platform !== "ios") {
3692
+ throw new CliError(platform ? `Unsupported platform "${platform}" — only "ios" for now.` : "Usage: lecodes app init ios [--name <display name>] [--bundle-id <id>] [--team <team id>] [--variant auto|core|2d|3d|full]");
3693
+ }
3694
+ const root = projectRoot();
3695
+ const iosDir = join11(root, "ios");
3696
+ if (existsSync8(iosDir)) {
3697
+ throw new CliError("ios/ already exists — run `lecodes app sync` to update it.");
3698
+ }
3699
+ const existing = readJson(join11(root, "app.json")) ?? {};
3700
+ const variant = flagStr(args, "variant") ?? existing.ios?.variant ?? "auto";
3701
+ if (variant !== "auto" && !SDK_VARIANTS.includes(variant)) {
3702
+ throw new CliError(`--variant must be auto, ${SDK_VARIANTS.join(", ")} (got "${variant}")`);
3703
+ }
3704
+ const name = flagStr(args, "name") ?? existing.name ?? basename4(root);
3705
+ const config = {
3706
+ ...existing,
3707
+ name,
3708
+ version: existing.version ?? "1.0.0",
3709
+ plugins: existing.plugins ?? {},
3710
+ ios: {
3711
+ bundleId: flagStr(args, "bundle-id") ?? existing.ios?.bundleId ?? `com.example.${slugify(name)}`,
3712
+ teamId: flagStr(args, "team") ?? existing.ios?.teamId ?? "",
3713
+ variant,
3714
+ buildNumber: existing.ios?.buildNumber ?? 1
2108
3715
  }
3716
+ };
3717
+ writeJson(join11(root, "app.json"), config);
3718
+ log(`${c.green("written")} app.json`);
3719
+ const ignorePath = join11(root, ".lecodesignore");
3720
+ const ignore = existsSync8(ignorePath) ? readFileSync10(ignorePath, "utf8") : DEFAULT_IGNORE_FILE;
3721
+ if (!/^ios\/$/m.test(ignore)) {
3722
+ writeFileSync7(ignorePath, ignore.replace(/\n*$/, `
3723
+ `) + `
3724
+ # Native app shells — generated by \`lecodes app init\`, never pushed to LeCodes.
3725
+ ios/
3726
+ `);
3727
+ log(`${c.green("updated")} .lecodesignore (+ ios/)`);
3728
+ }
3729
+ const identity = {
3730
+ displayName: name,
3731
+ bundleId: config.ios.bundleId,
3732
+ teamId: config.ios.teamId,
3733
+ version: config.version,
3734
+ buildNumber: config.ios.buildNumber
3735
+ };
3736
+ const write = (rel, content) => {
3737
+ const abs = join11(iosDir, rel);
3738
+ mkdirSync5(dirname7(abs), { recursive: true });
3739
+ writeFileSync7(abs, content);
3740
+ log(`${c.green("created")} ios/${rel}`);
3741
+ };
3742
+ write("README.md", IOS_README);
3743
+ write("App.xcodeproj/project.pbxproj", pbxprojTemplate(identity));
3744
+ write("App.xcodeproj/xcshareddata/xcschemes/App.xcscheme", SCHEME_XML);
3745
+ write("App/AppDelegate.swift", APP_DELEGATE);
3746
+ write("App/SceneDelegate.swift", SCENE_DELEGATE);
3747
+ write("App/ViewController.swift", VIEW_CONTROLLER);
3748
+ write("App/Info.plist", infoPlistTemplate({}));
3749
+ write("App/Base.lproj/LaunchScreen.storyboard", LAUNCH_STORYBOARD);
3750
+ write("App/Assets.xcassets/Contents.json", ASSETS_ROOT_JSON);
3751
+ write("App/Assets.xcassets/AccentColor.colorset/Contents.json", ACCENT_COLOR_JSON);
3752
+ write("App/Assets.xcassets/AppIcon.appiconset/Contents.json", APP_ICON_JSON);
3753
+ write("LeCodesRuntime/Package.swift", runtimePackageTemplate(SDK_PACKAGE_VERSION, variant === "auto" ? "core" : variant, []));
3754
+ write("LeCodesRuntime/Sources/LeCodesRuntime/LeCodesRuntime.swift", runtimeSwiftTemplate([]));
3755
+ log("");
3756
+ success(`iOS shell for ${c.bold(name)} ready.`);
3757
+ log(` ${c.bold("lecodes app sync")} embed the compiled bundle (+ plugins) into the shell`);
3758
+ log(` ${c.bold("open ios/App.xcodeproj")} then run on your device from Xcode`);
3759
+ log(` App icon: drop a 1024×1024 PNG into ios/App/Assets.xcassets/AppIcon.appiconset/`);
3760
+ };
3761
+ var detectEngines = (bundleJs) => {
3762
+ const lines = [];
3763
+ for (const line of bundleJs.split(`
3764
+ `)) {
3765
+ if (!line.startsWith("//"))
3766
+ break;
3767
+ lines.push(line);
3768
+ }
3769
+ const header = lines.join(`
3770
+ `);
3771
+ return { gl: !/^\/\/ gl: disable$/m.test(header), g2: /^\/\/ 2d: enable$/m.test(header) };
3772
+ };
3773
+ var resolveVariant = (requested, bundleJs) => {
3774
+ const uses = detectEngines(bundleJs);
3775
+ const detected = uses.gl && uses.g2 ? "full" : uses.gl ? "3d" : uses.g2 ? "2d" : "core";
3776
+ if (requested === "auto")
3777
+ return { variant: detected, detected };
3778
+ const variant = requested;
3779
+ const missing = [
3780
+ uses.gl && variant !== "3d" && variant !== "full" ? "3D" : null,
3781
+ uses.g2 && variant !== "2d" && variant !== "full" ? "2D" : null
3782
+ ].filter(Boolean);
3783
+ if (missing.length > 0) {
3784
+ warn(`project uses the ${missing.join(" + ")} engine but ios.variant forces "${variant}" — those scenes won't run on device; remove ios.variant to auto-select "${detected}".`);
2109
3785
  }
2110
- return { added, modified, deleted, moves };
3786
+ return { variant, detected };
2111
3787
  };
2112
- var hasChanges = (diff) => diff.added.length + diff.modified.length + diff.deleted.length > 0;
2113
-
2114
- // src/compile/collect.ts
2115
- var collectEntries = (root, manifest, project) => {
2116
- const pathById = buildPaths(project.assets);
2117
- const serverByPath = new Map;
2118
- for (const asset of project.assets) {
2119
- if (asset.type === "folder")
3788
+ var ensureSdkResourcesBundle = async () => {
3789
+ const override = process.env.LECODES_SDK_RESOURCES;
3790
+ if (override) {
3791
+ note(`Using local resources bundle ${override} (LECODES_SDK_RESOURCES)`);
3792
+ return resolve2(override);
3793
+ }
3794
+ const dir = join11(homedir2(), ".lecodes", "sdk-resources", SDK_PACKAGE_VERSION);
3795
+ const bundle = join11(dir, "LeCodesSDKResources.bundle");
3796
+ if (existsSync8(bundle))
3797
+ return bundle;
3798
+ const url = `https://github.com/letary/lecodes-ios-sdk/releases/download/${SDK_PACKAGE_VERSION}/LeCodesSDKResources.bundle.zip`;
3799
+ note(`Downloading LeCodesSDKResources.bundle (SDK ${SDK_PACKAGE_VERSION})…`);
3800
+ const res = await fetch(url);
3801
+ if (!res.ok)
3802
+ throw new CliError(`Resources bundle download failed (HTTP ${res.status}) — ${url}`);
3803
+ mkdirSync5(dir, { recursive: true });
3804
+ const zip = join11(dir, "LeCodesSDKResources.bundle.zip");
3805
+ writeFileSync7(zip, new Uint8Array(await res.arrayBuffer()));
3806
+ execFileSync("ditto", ["-x", "-k", zip, dir]);
3807
+ rmSync(zip);
3808
+ if (!existsSync8(bundle))
3809
+ throw new CliError(`No LeCodesSDKResources.bundle inside the release asset ${url}`);
3810
+ return bundle;
3811
+ };
3812
+ var appSync = async (_args) => {
3813
+ const root = projectRoot();
3814
+ const config = readJson(join11(root, "app.json"));
3815
+ const iosDir = join11(root, "ios");
3816
+ if (!config?.ios || !existsSync8(iosDir)) {
3817
+ throw new CliError("No iOS shell here — run `lecodes app init ios` first.");
3818
+ }
3819
+ const requestedVariant = config.ios.variant ?? "auto";
3820
+ if (requestedVariant !== "auto" && !SDK_VARIANTS.includes(requestedVariant)) {
3821
+ throw new CliError(`app.json ios.variant must be "auto" or one of ${SDK_VARIANTS.join(", ")} (got "${requestedVariant}").`);
3822
+ }
3823
+ const lockPath = join11(root, "plugins.lock.json");
3824
+ const oldLock = readJson(lockPath)?.plugins ?? {};
3825
+ const pluginSpecs = Object.entries(config.plugins ?? {});
3826
+ const runtimePlugins = [];
3827
+ const infoValues = {};
3828
+ const newLock = {};
3829
+ const sourcesDir = join11(iosDir, "LeCodesRuntime", "Sources");
3830
+ for (const [id, spec] of pluginSpecs) {
3831
+ const plugin = resolvePlugin(id, spec, oldLock[id]);
3832
+ const ios = plugin.manifest.ios;
3833
+ const module = ios.module;
3834
+ const register = ios.register;
3835
+ if (!module || !register)
3836
+ throw new CliError(`Plugin "${id}": manifest ios.module / ios.register missing`);
3837
+ const src = join11(plugin.dir, ios.sources ?? "ios");
3838
+ if (!existsSync8(src))
3839
+ throw new CliError(`Plugin "${id}": sources dir ${src} missing`);
3840
+ const dest = join11(sourcesDir, module);
3841
+ rmSync(dest, { recursive: true, force: true });
3842
+ cpSync(src, dest, { recursive: true });
3843
+ for (const [key, value] of Object.entries(ios.infoPlist ?? {})) {
3844
+ (infoValues[key] ??= []).push(value);
3845
+ }
3846
+ runtimePlugins.push({ id, module, register });
3847
+ newLock[id] = plugin.lock;
3848
+ log(`${c.green("plugin")} ${id}@${plugin.lock.version} (${plugin.lock.tag} → Sources/${module})`);
3849
+ }
3850
+ const keep = new Set(["LeCodesRuntime", ...runtimePlugins.map((p) => p.module)]);
3851
+ for (const entry of existsSync8(sourcesDir) ? readdirSync4(sourcesDir) : []) {
3852
+ if (!keep.has(entry) && statSync2(join11(sourcesDir, entry)).isDirectory()) {
3853
+ rmSync(join11(sourcesDir, entry), { recursive: true });
3854
+ log(`${c.yellow("removed")} ios/LeCodesRuntime/Sources/${entry} (plugin uninstalled)`);
3855
+ }
3856
+ }
3857
+ const remotePatterns = config.resources?.remote ?? [];
3858
+ if (!Array.isArray(remotePatterns) || remotePatterns.some((p) => typeof p !== "string")) {
3859
+ throw new CliError('app.json "resources.remote" must be an array of glob strings (e.g. ["videos/**"]).');
3860
+ }
3861
+ const resourcesDir = join11(iosDir, "App", "Resources");
3862
+ rmSync(resourcesDir, { recursive: true, force: true });
3863
+ mkdirSync5(resourcesDir, { recursive: true });
3864
+ const resources = await compileShell({ _: [], flags: { o: join11(resourcesDir, "app.js") } }, { remotePatterns });
3865
+ const bundleJs = readFileSync10(join11(resourcesDir, "app.js"), "utf8");
3866
+ const { variant, detected } = resolveVariant(requestedVariant, bundleJs);
3867
+ const WHY = {
3868
+ core: "UI only",
3869
+ "2d": "uses the 2D engine",
3870
+ "3d": "uses the 3D engine",
3871
+ full: "uses the 3D + 2D engines"
3872
+ };
3873
+ log(`${c.green("variant")} ${variant}${requestedVariant === "auto" ? ` (auto: ${WHY[detected]})` : " (forced by ios.variant)"}`);
3874
+ const sdkProvided = new Set;
3875
+ if (variant === "3d" || variant === "full") {
3876
+ const sdkResources = await ensureSdkResourcesBundle();
3877
+ cpSync(sdkResources, join11(resourcesDir, "LeCodesSDKResources.bundle"), { recursive: true });
3878
+ for (const f2 of readdirSync4(sdkResources))
3879
+ if (f2 !== "Info.plist")
3880
+ sdkProvided.add(f2);
3881
+ log(`${c.green("resources")} LeCodesSDKResources.bundle (filament) → ios/App/Resources/`);
3882
+ }
3883
+ const preload = /^\/\/ preload: (.*)$/m.exec(bundleJs)?.[1].split(";").map((s) => s.trim()).filter(Boolean) ?? [];
3884
+ const IMPLICIT_PRELOADS = new Set(["uberarchive", "capture"]);
3885
+ const byName = new Map;
3886
+ for (const r of resources) {
3887
+ if (r.remote || !r.absPath)
2120
3888
  continue;
2121
- serverByPath.set(pathById.get(asset.id), { fileSrc: asset.file?.src ?? undefined, shaders: asset.shaders ?? [] });
3889
+ const list = byName.get(r.name) ?? [];
3890
+ list.push(r);
3891
+ byName.set(r.name, list);
2122
3892
  }
2123
- const entries = [];
2124
- const warnings = [];
2125
- for (const file of scanLocal(root).files) {
2126
- const type = manifest.files[file.path]?.type ?? classifyKind(basename(file.path));
2127
- if (type === "resource") {
2128
- const src = serverByPath.get(file.path)?.fileSrc;
2129
- if (!src)
2130
- warnings.push(`No server URL for ${file.path} — push it so its URL resolves.`);
2131
- entries.push({ path: file.path, type, fileSrc: src, absPath: file.absPath });
2132
- } else if (type === "shader") {
2133
- const shaders = serverByPath.get(file.path)?.shaders;
2134
- if (!shaders?.length)
2135
- warnings.push(`No compiled shader for ${file.path} — push it to compile the shader.`);
2136
- entries.push({ path: file.path, type, shaders });
2137
- } else {
2138
- entries.push({ path: file.path, type, text: readFileSync6(file.absPath, "utf8") });
3893
+ let embedded = 0;
3894
+ for (const name of preload) {
3895
+ if (IMPLICIT_PRELOADS.has(name))
3896
+ continue;
3897
+ const candidates = byName.get(name) ?? [];
3898
+ if (candidates.length === 0) {
3899
+ if (!sdkProvided.has(name))
3900
+ warn(`bundle needs local asset "${name}" but no project file provides it — it won't load on device`);
3901
+ continue;
2139
3902
  }
2140
- }
2141
- return { entries, warnings };
2142
- };
2143
-
2144
- // src/compile/collectLocal.ts
2145
- import { readFileSync as readFileSync7 } from "node:fs";
2146
- import { basename as basename2 } from "node:path";
2147
- var collectLocalEntries = (root, manifest, assetBase) => {
2148
- const entries = [];
2149
- const warnings = [];
2150
- const scan = scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest?.files ?? {})) });
2151
- for (const file of scan.files) {
2152
- const type = manifest?.files[file.path]?.type ?? classifyKind(basename2(file.path));
2153
- if (type === "resource") {
2154
- entries.push({ path: file.path, type, fileSrc: assetBase + encodeURI(file.path), absPath: file.absPath });
2155
- } else if (type === "shader") {
2156
- warnings.push(`${file.path}: shaders compile on the server — push the project once for shaders to work in dev mode.`);
2157
- entries.push({ path: file.path, type, shaders: [] });
3903
+ if (candidates.length > 1)
3904
+ warn(`asset name collision: ${name} (${candidates.map((r) => r.path).join(", ")}) — embedding ${candidates[0].path}`);
3905
+ cpSync(candidates[0].absPath, join11(resourcesDir, name));
3906
+ embedded++;
3907
+ }
3908
+ if (embedded > 0)
3909
+ log(`${c.green("assets")} ${embedded} file(s) embedded → ios/App/Resources/`);
3910
+ const streamed = resources.filter((r) => r.remote);
3911
+ if (streamed.length > 0)
3912
+ log(`${c.green("remote")} ${streamed.length} resource(s) stay on the server (resources.remote): ${streamed.map((r) => r.path).join(", ")}`);
3913
+ writeFileSync7(join11(iosDir, "README.md"), IOS_README);
3914
+ writeFileSync7(join11(iosDir, "LeCodesRuntime", "Package.swift"), runtimePackageTemplate(SDK_PACKAGE_VERSION, variant, runtimePlugins));
3915
+ writeFileSync7(join11(sourcesDir, "LeCodesRuntime", "LeCodesRuntime.swift"), runtimeSwiftTemplate(runtimePlugins));
3916
+ const mergedKeys = {};
3917
+ for (const [key, values] of Object.entries(infoValues))
3918
+ mergedKeys[key] = [...new Set(values)].join(" ");
3919
+ writeFileSync7(join11(iosDir, "App", "Info.plist"), infoPlistTemplate(mergedKeys));
3920
+ if (pluginSpecs.length > 0 || existsSync8(lockPath))
3921
+ writeJson(lockPath, { plugins: newLock });
3922
+ const pbxPath = join11(iosDir, "App.xcodeproj", "project.pbxproj");
3923
+ const pbx = readFileSync10(pbxPath, "utf8").replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${config.version ?? "1.0.0"};`).replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${config.ios.buildNumber ?? 1};`);
3924
+ writeFileSync7(pbxPath, pbx);
3925
+ log("");
3926
+ success(`Shell synced: app.js + ${runtimePlugins.length} plugin(s), variant ${variant}, v${config.version ?? "1.0.0"} (${config.ios.buildNumber ?? 1}).`);
3927
+ note("Build & run from Xcode (open ios/App.xcodeproj) — user files in ios/App/*.swift were not touched.");
3928
+ };
3929
+ var appPlugin = async (args) => {
3930
+ const sub = args._[1];
3931
+ const root = projectRoot();
3932
+ const configPath = join11(root, "app.json");
3933
+ const config = readJson(configPath);
3934
+ if (!config)
3935
+ throw new CliError("No app.json here — run `lecodes app init ios` first.");
3936
+ config.plugins ??= {};
3937
+ if (sub === "list" || sub === undefined) {
3938
+ const entries = Object.entries(config.plugins);
3939
+ if (entries.length === 0) {
3940
+ log("No plugins installed.");
3941
+ return;
3942
+ }
3943
+ const lock = readJson(join11(root, "plugins.lock.json"))?.plugins ?? {};
3944
+ for (const [id2, spec] of entries) {
3945
+ const pin = lock[id2] ? c.dim(` ${lock[id2].repo} ${lock[id2].tag} (${lock[id2].commit.slice(0, 7)})`) : c.dim(" (not synced)");
3946
+ log(`${c.bold(id2)} ${spec}${pin}`);
3947
+ }
3948
+ return;
3949
+ }
3950
+ const id = args._[2];
3951
+ if (!id)
3952
+ throw new CliError(`Usage: lecodes app plugin ${sub} <id | github:owner/repo[@tag]>`);
3953
+ if (sub === "add") {
3954
+ if (id.startsWith("github:")) {
3955
+ const bare = id.slice("github:".length).split("@")[0].split("/").pop();
3956
+ config.plugins[bare] = id;
3957
+ log(`${c.green("added")} ${bare} → ${id} ${c.dim("(third-party native code — review it before shipping)")}`);
3958
+ } else if (FIRST_PARTY_PLUGINS[id]) {
3959
+ config.plugins[id] = "*";
3960
+ log(`${c.green("added")} ${id}`);
2158
3961
  } else {
2159
- entries.push({ path: file.path, type, text: readFileSync7(file.absPath, "utf8") });
3962
+ throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use github:owner/repo[@tag].`);
2160
3963
  }
3964
+ writeJson(configPath, config);
3965
+ note("Run `lecodes app sync` to vendor it into the shell.");
3966
+ return;
2161
3967
  }
2162
- return { entries, warnings };
3968
+ if (sub === "remove") {
3969
+ if (!(id in config.plugins))
3970
+ throw new CliError(`Plugin "${id}" is not installed.`);
3971
+ delete config.plugins[id];
3972
+ writeJson(configPath, config);
3973
+ log(`${c.yellow("removed")} ${id}`);
3974
+ note("Run `lecodes app sync` to drop its sources from the shell.");
3975
+ return;
3976
+ }
3977
+ throw new CliError(`Unknown subcommand "plugin ${sub}" — use list, add or remove.`);
2163
3978
  };
2164
-
2165
- // src/compile/projectCompile.ts
2166
- import { existsSync as existsSync8 } from "node:fs";
2167
- import { createRequire as createRequire3 } from "node:module";
2168
- import { dirname as dirname6, join as join10 } from "node:path";
2169
- init_compile();
2170
- var vendoredInject = join10(distRoot, "runtime", "sdk", "inject.ts");
2171
- if (existsSync8(vendoredInject))
2172
- process.env.LECODES_SDK_INJECT = vendoredInject;
2173
- var resolveChiselBin2 = () => {
2174
- if (process.env.LECODES_CHISEL_BIN)
2175
- return process.env.LECODES_CHISEL_BIN;
3979
+ var runTool = (cwd, cmd, args) => {
2176
3980
  try {
2177
- const fromPkg = createRequire3(import.meta.url)("@letary/chisel").binaryPath();
2178
- if (fromPkg)
2179
- return fromPkg;
3981
+ return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 128 * 1024 * 1024 });
3982
+ } catch (e) {
3983
+ const err = e;
3984
+ const tail = ((err.stdout ?? "") + `
3985
+ ` + (err.stderr ?? "")).split(`
3986
+ `).filter((l) => l.trim()).slice(-25).join(`
3987
+ `);
3988
+ throw new CliError(`${cmd} ${args[0] ?? ""} failed:
3989
+ ${tail}`);
3990
+ }
3991
+ };
3992
+ var exportOptionsPlist = (method, teamId, destination) => `<?xml version="1.0" encoding="UTF-8"?>
3993
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3994
+ <plist version="1.0">
3995
+ <dict>
3996
+ <key>method</key>
3997
+ <string>${method}</string>
3998
+ <key>signingStyle</key>
3999
+ <string>automatic</string>${teamId ? `
4000
+ <key>teamID</key>
4001
+ <string>${teamId}</string>` : ""}${destination ? `
4002
+ <key>destination</key>
4003
+ <string>${destination}</string>` : ""}
4004
+ </dict>
4005
+ </plist>
4006
+ `;
4007
+ var requireShell = () => {
4008
+ const root = projectRoot();
4009
+ const config = readJson(join11(root, "app.json"));
4010
+ if (!config?.ios || !existsSync8(join11(root, "ios"))) {
4011
+ throw new CliError("No iOS shell here — run `lecodes app init ios` first.");
4012
+ }
4013
+ return { root, config };
4014
+ };
4015
+ var appDoctor = async (_args) => {
4016
+ const { root, config } = requireShell();
4017
+ let failed = 0;
4018
+ const check = (ok, label, hint = "", fatal = true) => {
4019
+ log(`${ok ? c.green("✓") : fatal ? c.red("✗") : c.yellow("!")} ${label}${ok || !hint ? "" : c.dim(" — " + hint)}`);
4020
+ if (!ok && fatal)
4021
+ failed++;
4022
+ };
4023
+ let xcodeVersion = "";
4024
+ try {
4025
+ xcodeVersion = runTool(root, "xcodebuild", ["-version"]).split(`
4026
+ `)[0];
2180
4027
  } catch {}
2181
- const local = join10(dirname6(process.execPath), process.platform === "win32" ? "chisel.exe" : "chisel");
2182
- return existsSync8(local) ? local : null;
4028
+ check(xcodeVersion !== "", `Xcode CLI (${xcodeVersion || "not found"})`, "install Xcode + command line tools");
4029
+ check(!!config.ios.teamId, "Development team set", "lecodes app init wrote app.json — fill ios.teamId", false);
4030
+ const iconDir = join11(root, "ios", "App", "Assets.xcassets", "AppIcon.appiconset");
4031
+ const hasIcon = existsSync8(iconDir) && readdirSync4(iconDir).some((f2) => f2.endsWith(".png"));
4032
+ check(hasIcon, "App icon present", "drop a 1024×1024 PNG into ios/App/Assets.xcassets/AppIcon.appiconset/ (App Store requires it)", false);
4033
+ const bundle = join11(root, "ios", "App", "Resources", "app.js");
4034
+ check(existsSync8(bundle), "Bundled app.js", "run `lecodes app sync`");
4035
+ const requestedVariant = config.ios.variant ?? "auto";
4036
+ const validVariant = requestedVariant === "auto" || SDK_VARIANTS.includes(requestedVariant);
4037
+ check(validVariant, `ios.variant "${requestedVariant}"`, `use "auto" or one of ${SDK_VARIANTS.join(", ")}`);
4038
+ if (existsSync8(bundle) && validVariant) {
4039
+ const { variant } = resolveVariant(requestedVariant, readFileSync10(bundle, "utf8"));
4040
+ const pkg = readFileSync10(join11(root, "ios", "LeCodesRuntime", "Package.swift"), "utf8");
4041
+ const needsResources = variant === "3d" || variant === "full";
4042
+ const hasResources = existsSync8(join11(root, "ios", "App", "Resources", "LeCodesSDKResources.bundle"));
4043
+ check(pkg.includes(`"LeCodesSDK-${variant}"`) && (!needsResources || hasResources), `SDK variant ${variant}${needsResources ? " (+ filament resources)" : ""}`, "run `lecodes app sync`");
4044
+ }
4045
+ const lock = readJson(join11(root, "plugins.lock.json"))?.plugins ?? {};
4046
+ const wanted = Object.keys(config.plugins ?? {});
4047
+ const inSync = wanted.every((id) => lock[id]) && Object.keys(lock).every((id) => wanted.includes(id));
4048
+ check(wanted.length === 0 || inSync, "Plugins synced", "run `lecodes app sync`");
4049
+ const asc = readJson(join11(homedir2(), ".lecodes", "asc.json"));
4050
+ 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
+ log("");
4052
+ if (failed > 0)
4053
+ throw new CliError(`${failed} check(s) failed.`);
4054
+ success("Ready to build.");
4055
+ };
4056
+ var ARCHIVE_PATH = "ios/build/App.xcarchive";
4057
+ var appBuild = async (args) => {
4058
+ const { root, config } = requireShell();
4059
+ const configuration = flagStr(args, "configuration") ?? "Release";
4060
+ const method = flagStr(args, "method") ?? "app-store-connect";
4061
+ if (!flagBool(args, "no-sync"))
4062
+ await appSync({ _: [], flags: {} });
4063
+ note(`Archiving (${configuration})… this takes a few minutes`);
4064
+ runTool(root, "xcodebuild", [
4065
+ "-project",
4066
+ "ios/App.xcodeproj",
4067
+ "-scheme",
4068
+ "App",
4069
+ "-destination",
4070
+ "generic/platform=iOS",
4071
+ "-configuration",
4072
+ configuration,
4073
+ "archive",
4074
+ "-archivePath",
4075
+ ARCHIVE_PATH,
4076
+ "-allowProvisioningUpdates"
4077
+ ]);
4078
+ success(`Archive: ios/build/App.xcarchive`);
4079
+ if (flagBool(args, "no-export"))
4080
+ return;
4081
+ const optionsPath = join11(root, "ios", "build", "ExportOptions.plist");
4082
+ writeFileSync7(optionsPath, exportOptionsPlist(method, config.ios.teamId ?? ""));
4083
+ note(`Exporting (.ipa, method ${method})…`);
4084
+ runTool(root, "xcodebuild", [
4085
+ "-exportArchive",
4086
+ "-archivePath",
4087
+ ARCHIVE_PATH,
4088
+ "-exportOptionsPlist",
4089
+ optionsPath,
4090
+ "-exportPath",
4091
+ "ios/build/export",
4092
+ "-allowProvisioningUpdates"
4093
+ ]);
4094
+ success("Export: ios/build/export/ — upload with `lecodes app upload ios`");
4095
+ };
4096
+ var appUpload = async (args) => {
4097
+ const { root, config } = requireShell();
4098
+ if (!existsSync8(join11(root, ARCHIVE_PATH))) {
4099
+ throw new CliError("No archive at ios/build/App.xcarchive — run `lecodes app build ios` first.");
4100
+ }
4101
+ const ascPath = join11(homedir2(), ".lecodes", "asc.json");
4102
+ const stored = readJson(ascPath) ?? {};
4103
+ const keyPath = flagStr(args, "key-path") ?? stored.keyPath;
4104
+ const keyId = flagStr(args, "key-id") ?? stored.keyId;
4105
+ const issuerId = flagStr(args, "issuer-id") ?? stored.issuerId;
4106
+ if (!keyPath || !keyId || !issuerId) {
4107
+ throw new CliError(`App Store Connect API key needed: --key-path <AuthKey.p8> --key-id <id> --issuer-id <id>
4108
+ (create one at appstoreconnect.apple.com → Users and Access → Integrations; stored in ~/.lecodes/asc.json after first use)`);
4109
+ }
4110
+ if (!existsSync8(keyPath))
4111
+ throw new CliError(`API key file not found: ${keyPath}`);
4112
+ if (flagStr(args, "key-path")) {
4113
+ mkdirSync5(dirname7(ascPath), { recursive: true });
4114
+ writeJson(ascPath, { keyPath, keyId, issuerId });
4115
+ note("Stored the key reference in ~/.lecodes/asc.json");
4116
+ }
4117
+ const optionsPath = join11(root, "ios", "build", "UploadOptions.plist");
4118
+ writeFileSync7(optionsPath, exportOptionsPlist("app-store-connect", config.ios.teamId ?? "", "upload"));
4119
+ note("Uploading to App Store Connect… (processing continues on Apple's side)");
4120
+ runTool(root, "xcodebuild", [
4121
+ "-exportArchive",
4122
+ "-archivePath",
4123
+ ARCHIVE_PATH,
4124
+ "-exportOptionsPlist",
4125
+ optionsPath,
4126
+ "-exportPath",
4127
+ "ios/build/upload",
4128
+ "-authenticationKeyPath",
4129
+ resolve2(keyPath),
4130
+ "-authenticationKeyID",
4131
+ keyId,
4132
+ "-authenticationKeyIssuerID",
4133
+ issuerId,
4134
+ "-allowProvisioningUpdates"
4135
+ ]);
4136
+ const configPath = join11(root, "app.json");
4137
+ const fresh = readJson(configPath);
4138
+ fresh.ios.buildNumber = (fresh.ios.buildNumber ?? 1) + 1;
4139
+ writeJson(configPath, fresh);
4140
+ success(`Uploaded build ${(fresh.ios.buildNumber ?? 2) - 1} — check App Store Connect / TestFlight once processing finishes.`);
4141
+ note(`ios.buildNumber bumped to ${fresh.ios.buildNumber} for the next build.`);
4142
+ };
4143
+ var appOpen = async (args) => {
4144
+ const root = projectRoot();
4145
+ const platform = args._[1] ?? "ios";
4146
+ if (platform === "ios") {
4147
+ const proj = join11(root, "ios", "App.xcodeproj");
4148
+ if (!existsSync8(proj))
4149
+ throw new CliError("No iOS shell here — run `lecodes app init ios` first.");
4150
+ if (process.platform !== "darwin")
4151
+ throw new CliError("Opening Xcode requires macOS.");
4152
+ execFileSync("open", [proj], { stdio: "ignore" });
4153
+ success("Opened ios/App.xcodeproj in Xcode.");
4154
+ return;
4155
+ }
4156
+ if (platform === "android") {
4157
+ throw new CliError("Android shells don't exist yet — `lecodes app open` covers ios for now.");
4158
+ }
4159
+ throw new CliError(`Unknown platform "${platform}" — use ios.`);
4160
+ };
4161
+ var app = async (args) => {
4162
+ const sub = args._[0];
4163
+ if (sub === "init")
4164
+ return appInit(args);
4165
+ if (sub === "sync")
4166
+ return appSync(args);
4167
+ if (sub === "plugin")
4168
+ return appPlugin(args);
4169
+ if (sub === "doctor")
4170
+ return appDoctor(args);
4171
+ if (sub === "build")
4172
+ return appBuild(args);
4173
+ if (sub === "upload")
4174
+ return appUpload(args);
4175
+ if (sub === "open")
4176
+ return appOpen(args);
4177
+ throw new CliError("Usage: lecodes app <init ios | sync | open | plugin list|add|remove | doctor | build ios | upload ios> — see `lecodes help`.");
2183
4178
  };
2184
- var chiselBin = resolveChiselBin2();
2185
- if (chiselBin)
2186
- process.env.LECODES_CHISEL_BIN = chiselBin;
2187
- var compileProject2 = (opts) => compileProject({
2188
- entries: opts.entries.map((e) => ({ path: e.path, type: e.type, text: e.text, fileSrc: e.fileSrc, shaders: e.shaders })),
2189
- name: opts.name,
2190
- publicUrl: opts.publicUrl,
2191
- entryOverride: opts.entryOverride,
2192
- header: opts.header,
2193
- format: opts.format,
2194
- localAssets: opts.localAssets,
2195
- minify: opts.minify,
2196
- sourcemap: opts.sourcemap,
2197
- editor: opts.editor
2198
- });
2199
4179
 
2200
- // src/commands/compile.ts
2201
- var compile2 = async (args) => {
2202
- let root;
2203
- let manifest;
2204
- try {
2205
- root = findProjectRoot(process.cwd());
2206
- manifest = readManifest(root);
2207
- } catch {
2208
- root = process.cwd();
2209
- manifest = null;
2210
- }
2211
- let entries;
2212
- let warnings;
2213
- let name;
2214
- let publicUrl;
2215
- let localAssets = false;
2216
- if (manifest) {
2217
- const config = loadConfig();
2218
- const apiUrl = requireApiUrl(config);
2219
- const token = requireToken(config);
2220
- note("Resolving project assets…");
2221
- const project = await getProject(apiUrl, token, manifest.uuid);
2222
- ({ entries, warnings } = collectEntries(root, manifest, project));
2223
- name = manifest.name;
2224
- publicUrl = flagStr(args, "public-url") ?? apiUrl;
2225
- } else {
2226
- const publicUrlFlag = flagStr(args, "public-url");
2227
- localAssets = publicUrlFlag === undefined;
2228
- publicUrl = publicUrlFlag ?? "";
2229
- ({ entries, warnings } = collectLocalEntries(root, null, publicUrl));
2230
- name = basename3(root);
4180
+ // src/commands/clone.ts
4181
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, readdirSync as readdirSync5 } from "node:fs";
4182
+ import { resolve as resolve3 } from "node:path";
4183
+
4184
+ // src/types.ts
4185
+ import { dirname as dirname8, join as join12 } from "node:path";
4186
+ import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "node:fs";
4187
+ var TSCONFIG = {
4188
+ compilerOptions: {
4189
+ target: "ES2020",
4190
+ module: "ESNext",
4191
+ moduleResolution: "bundler",
4192
+ lib: ["ES2020"],
4193
+ types: [],
4194
+ allowJs: true,
4195
+ checkJs: false,
4196
+ strict: false,
4197
+ noEmit: true,
4198
+ skipLibCheck: true
4199
+ },
4200
+ include: [`${LECODES_DIR}/types/**/*.d.ts`, "**/*.ts", "**/*.js"],
4201
+ exclude: ["node_modules"]
4202
+ };
4203
+ var writeTypeBundle = (root, bundle) => {
4204
+ const typesDir = join12(root, LECODES_DIR, "types");
4205
+ for (const [path, text] of Object.entries(bundle)) {
4206
+ const dest = join12(typesDir, path);
4207
+ mkdirSync6(dirname8(dest), { recursive: true });
4208
+ writeFileSync8(dest, text);
2231
4209
  }
2232
- note("Compiling…");
2233
- const js = await compileProject2({
2234
- entries,
2235
- name,
2236
- publicUrl,
2237
- entryOverride: flagStr(args, "entry"),
2238
- header: !flagBool(args, "no-header"),
2239
- localAssets,
2240
- minify: flagBool(args, "no-minify") ? false : undefined,
2241
- sourcemap: flagBool(args, "no-sourcemap", "no-source-map") ? false : undefined
2242
- });
2243
- for (const w of warnings)
2244
- warnErr(w);
2245
- const out = flagStr(args, "o", "out");
2246
- if (out && isNullSink(out)) {
2247
- note(`Discarded output (${out})`);
2248
- } else if (out) {
2249
- writeFileSync6(resolve2(out), js);
2250
- note(`Wrote ${out} (${js.length} bytes)`);
2251
- } else {
2252
- process.stdout.write(js);
2253
- if (!js.endsWith(`
2254
- `))
2255
- process.stdout.write(`
2256
- `);
4210
+ };
4211
+ var materializeTypes = async (root, apiUrl) => {
4212
+ writeTypeBundle(root, await getTypes(apiUrl));
4213
+ };
4214
+ var readLocalTypeBundle = () => {
4215
+ const candidates = [
4216
+ join12(distRoot, "runtime", "sdk-types.json"),
4217
+ join12(distRoot, "..", "sdk", "dist", "types.json")
4218
+ ];
4219
+ for (const path of candidates) {
4220
+ if (existsSync9(path)) {
4221
+ try {
4222
+ return JSON.parse(readFileSync11(path, "utf8"));
4223
+ } catch {}
4224
+ }
2257
4225
  }
4226
+ return null;
2258
4227
  };
2259
- var previewLegacy = async (args) => {
2260
- warnErr("'lecodes preview' is now 'lecodes compile' — the old name still works.");
2261
- await compile2(args);
4228
+ var materializeTypesLocal = (root) => {
4229
+ const bundle = readLocalTypeBundle();
4230
+ if (!bundle)
4231
+ return false;
4232
+ writeTypeBundle(root, bundle);
4233
+ return true;
4234
+ };
4235
+ var writeTsconfig = (root) => {
4236
+ writeFileSync8(join12(root, "tsconfig.json"), JSON.stringify(TSCONFIG, null, 2));
4237
+ };
4238
+
4239
+ // src/commands/clone.ts
4240
+ var parseUuid = (input) => {
4241
+ if (!input)
4242
+ throw new CliError("Usage: lecodes clone <project-uuid|url> [directory]");
4243
+ if (!input.includes("/"))
4244
+ return input;
4245
+ const clean = input.split(/[?#]/)[0].replace(/\/+$/, "");
4246
+ const match = clean.match(/\/(?:projects|view)\/([^/]+)/);
4247
+ return match ? match[1] : clean.slice(clean.lastIndexOf("/") + 1);
4248
+ };
4249
+ var slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
4250
+ var clone = async (args) => {
4251
+ const config = loadConfig();
4252
+ const apiUrl = requireApiUrl(config);
4253
+ const token = requireToken(config);
4254
+ const uuid = parseUuid(args._[0]);
4255
+ const project = await getProject(apiUrl, token, uuid);
4256
+ const dir = args._[1] ?? slug(project.name);
4257
+ const root = resolve3(dir);
4258
+ if (existsSync10(root) && readdirSync5(root).length > 0) {
4259
+ throw new CliError(`Directory "${dir}" already exists and is not empty.`);
4260
+ }
4261
+ mkdirSync7(root, { recursive: true });
4262
+ info(c.dim(`Fetching project ${c.bold(project.name)}…`));
4263
+ const files = await materializeAssets(root, project, apiUrl, token, progress);
4264
+ endProgress();
4265
+ if (!flagBool(args, "no-types")) {
4266
+ info(c.dim("Fetching type definitions…"));
4267
+ await materializeTypes(root, apiUrl);
4268
+ writeTsconfig(root);
4269
+ }
4270
+ writeDefaultIgnore(root);
4271
+ const commits = await getCommits(apiUrl, token, uuid);
4272
+ writeManifest(root, { uuid, apiUrl, name: project.name, baseHeadId: commits.headId, files });
4273
+ success(`Cloned ${c.bold(project.name)} into ${c.bold(dir)}/ (${Object.keys(files).length} files)`);
4274
+ info(c.dim(` cd ${dir} — edit, then \`lecodes push -m "your message"\``));
2262
4275
  };
2263
4276
 
2264
4277
  // src/commands/create.ts
2265
- import { existsSync as existsSync9, mkdirSync as mkdirSync6, readdirSync as readdirSync5, writeFileSync as writeFileSync7 } from "node:fs";
2266
- import { join as join11, resolve as resolve3 } from "node:path";
4278
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync9 } from "node:fs";
4279
+ import { join as join13, resolve as resolve4 } from "node:path";
2267
4280
  var slug2 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2268
4281
  var STARTER = `// Welcome to le.codes — no imports needed, the whole SDK is available as globals.
2269
4282
  const scene = new Scene2D({ background: '#10131a' })
@@ -2281,18 +4294,18 @@ var create = async (args) => {
2281
4294
  if (!name)
2282
4295
  throw new CliError("Usage: lecodes create <name> [directory]");
2283
4296
  const dir = args._[1] ?? slug2(name);
2284
- const root = resolve3(dir);
2285
- if (existsSync9(root) && readdirSync5(root).length > 0) {
4297
+ const root = resolve4(dir);
4298
+ if (existsSync11(root) && readdirSync6(root).length > 0) {
2286
4299
  throw new CliError(`Directory "${dir}" already exists and is not empty.`);
2287
4300
  }
2288
- mkdirSync6(root, { recursive: true });
4301
+ mkdirSync8(root, { recursive: true });
2289
4302
  info(c.dim(`Creating project ${c.bold(name)}…`));
2290
4303
  const { uuid } = await createProject(apiUrl, token, name, flagStr(args, "team"));
2291
4304
  info(c.dim("Fetching type definitions…"));
2292
4305
  await materializeTypes(root, apiUrl);
2293
4306
  writeTsconfig(root);
2294
4307
  writeDefaultIgnore(root);
2295
- writeFileSync7(join11(root, "main.ts"), STARTER);
4308
+ writeFileSync9(join13(root, "main.ts"), STARTER);
2296
4309
  const commits = await getCommits(apiUrl, token, uuid);
2297
4310
  writeManifest(root, { uuid, apiUrl, name, baseHeadId: commits.headId, files: {} });
2298
4311
  success(`Created ${c.bold(name)} in ${c.bold(dir)}/`);
@@ -2300,22 +4313,22 @@ var create = async (args) => {
2300
4313
  };
2301
4314
 
2302
4315
  // src/commands/design.ts
2303
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync12, readdirSync as readdirSync6, writeFileSync as writeFileSync9, appendFileSync } from "node:fs";
2304
- import { basename as basename5, join as join13 } from "node:path";
4316
+ import { existsSync as existsSync13, mkdirSync as mkdirSync10, readFileSync as readFileSync14, readdirSync as readdirSync7, writeFileSync as writeFileSync11, appendFileSync } from "node:fs";
4317
+ import { basename as basename6, join as join15 } from "node:path";
2305
4318
 
2306
4319
  // src/compile/designCompile.ts
2307
- import { readFileSync as readFileSync11 } from "node:fs";
2308
- import { basename as basename4 } from "node:path";
4320
+ import { readFileSync as readFileSync13 } from "node:fs";
4321
+ import { basename as basename5 } from "node:path";
2309
4322
 
2310
4323
  // src/compile/assetIcons.ts
2311
- import { createHash as createHash2 } from "node:crypto";
2312
- import { existsSync as existsSync10, mkdirSync as mkdirSync7, readFileSync as readFileSync10, writeFileSync as writeFileSync8 } from "node:fs";
2313
- import { dirname as dirname7, join as join12 } from "node:path";
4324
+ import { createHash as createHash3 } from "node:crypto";
4325
+ import { existsSync as existsSync12, mkdirSync as mkdirSync9, readFileSync as readFileSync12, writeFileSync as writeFileSync10 } from "node:fs";
4326
+ import { dirname as dirname9, join as join14 } from "node:path";
2314
4327
  var DEFAULT_REGISTRY = process.env.LECODES_ICON_REGISTRY?.trim() || "https://icon-registry.jt3.ru/v1";
2315
4328
  var ICON_ID = /^([a-z0-9]+(?:-[a-z0-9]+)*):([a-z0-9]+(?:-[a-z0-9]+)*)$/;
2316
- var isIdentStart = (c2) => c2 >= "A" && c2 <= "Z" || c2 >= "a" && c2 <= "z" || c2 === "_" || c2 === "$";
2317
- var isIdentPart = (c2) => isIdentStart(c2) || c2 >= "0" && c2 <= "9";
2318
- var skipString = (src, start) => {
4329
+ var isIdentStart2 = (c2) => c2 >= "A" && c2 <= "Z" || c2 >= "a" && c2 <= "z" || c2 === "_" || c2 === "$";
4330
+ var isIdentPart2 = (c2) => isIdentStart2(c2) || c2 >= "0" && c2 <= "9";
4331
+ var skipString2 = (src, start) => {
2319
4332
  const quote = src[start];
2320
4333
  const n = src.length;
2321
4334
  let i = start + 1;
@@ -2326,7 +4339,7 @@ var skipString = (src, start) => {
2326
4339
  continue;
2327
4340
  }
2328
4341
  if (quote === "`" && c2 === "$" && src[i + 1] === "{") {
2329
- i = skipBraces(src, i + 1);
4342
+ i = skipBraces2(src, i + 1);
2330
4343
  continue;
2331
4344
  }
2332
4345
  if (c2 === quote)
@@ -2335,14 +4348,14 @@ var skipString = (src, start) => {
2335
4348
  }
2336
4349
  return n;
2337
4350
  };
2338
- var skipBraces = (src, open) => {
4351
+ var skipBraces2 = (src, open) => {
2339
4352
  const n = src.length;
2340
4353
  let depth = 0;
2341
4354
  let i = open;
2342
4355
  while (i < n) {
2343
4356
  const c2 = src[i];
2344
4357
  if (c2 === '"' || c2 === "'" || c2 === "`") {
2345
- i = skipString(src, i);
4358
+ i = skipString2(src, i);
2346
4359
  continue;
2347
4360
  }
2348
4361
  if (c2 === "{")
@@ -2356,14 +4369,14 @@ var skipBraces = (src, open) => {
2356
4369
  }
2357
4370
  return n;
2358
4371
  };
2359
- var matchParen = (src, open) => {
4372
+ var matchParen2 = (src, open) => {
2360
4373
  const n = src.length;
2361
4374
  let depth = 0;
2362
4375
  let i = open;
2363
4376
  while (i < n) {
2364
4377
  const c2 = src[i];
2365
4378
  if (c2 === '"' || c2 === "'" || c2 === "`") {
2366
- i = skipString(src, i);
4379
+ i = skipString2(src, i);
2367
4380
  continue;
2368
4381
  }
2369
4382
  if (c2 === "/" && src[i + 1] === "/") {
@@ -2391,7 +4404,7 @@ var matchParen = (src, open) => {
2391
4404
  }
2392
4405
  return -1;
2393
4406
  };
2394
- var splitTopLevel = (text, delim) => {
4407
+ var splitTopLevel2 = (text, delim) => {
2395
4408
  const parts = [];
2396
4409
  const n = text.length;
2397
4410
  let depth = 0;
@@ -2400,7 +4413,7 @@ var splitTopLevel = (text, delim) => {
2400
4413
  while (i < n) {
2401
4414
  const c2 = text[i];
2402
4415
  if (c2 === '"' || c2 === "'" || c2 === "`") {
2403
- i = skipString(text, i);
4416
+ i = skipString2(text, i);
2404
4417
  continue;
2405
4418
  }
2406
4419
  if (c2 === "(" || c2 === "[" || c2 === "{")
@@ -2416,7 +4429,7 @@ var splitTopLevel = (text, delim) => {
2416
4429
  parts.push(text.slice(last));
2417
4430
  return parts;
2418
4431
  };
2419
- var stringLiteralValue = (text) => {
4432
+ var stringLiteralValue2 = (text) => {
2420
4433
  const t = text.trim();
2421
4434
  if (t.length < 2)
2422
4435
  return null;
@@ -2437,7 +4450,7 @@ var scanAssetIconCalls = (source) => {
2437
4450
  while (i < n) {
2438
4451
  const c2 = source[i];
2439
4452
  if (c2 === '"' || c2 === "'" || c2 === "`") {
2440
- i = skipString(source, i);
4453
+ i = skipString2(source, i);
2441
4454
  continue;
2442
4455
  }
2443
4456
  if (c2 === "/" && source[i + 1] === "/") {
@@ -2454,12 +4467,12 @@ var scanAssetIconCalls = (source) => {
2454
4467
  i += 2;
2455
4468
  continue;
2456
4469
  }
2457
- if (isIdentStart(c2)) {
4470
+ if (isIdentStart2(c2)) {
2458
4471
  let j = i + 1;
2459
- while (j < n && isIdentPart(source[j]))
4472
+ while (j < n && isIdentPart2(source[j]))
2460
4473
  j++;
2461
- if (source.slice(i, j) === "assetIcon" && prevNonSpace(source, i) !== ".") {
2462
- const call = parseCall(source, i, j);
4474
+ if (source.slice(i, j) === "assetIcon" && prevNonSpace2(source, i) !== ".") {
4475
+ const call = parseCall2(source, i, j);
2463
4476
  if (call) {
2464
4477
  calls.push(call);
2465
4478
  i = call.end;
@@ -2473,25 +4486,25 @@ var scanAssetIconCalls = (source) => {
2473
4486
  }
2474
4487
  return calls;
2475
4488
  };
2476
- var prevNonSpace = (src, i) => {
4489
+ var prevNonSpace2 = (src, i) => {
2477
4490
  let k = i - 1;
2478
4491
  while (k >= 0 && /\s/.test(src[k]))
2479
4492
  k--;
2480
4493
  return k >= 0 ? src[k] : "";
2481
4494
  };
2482
- var parseCall = (src, start, identEnd) => {
4495
+ var parseCall2 = (src, start, identEnd) => {
2483
4496
  const n = src.length;
2484
4497
  let i = identEnd;
2485
4498
  while (i < n && /\s/.test(src[i]))
2486
4499
  i++;
2487
4500
  if (src[i] !== "(")
2488
4501
  return null;
2489
- const close = matchParen(src, i);
4502
+ const close = matchParen2(src, i);
2490
4503
  if (close === -1)
2491
4504
  return null;
2492
4505
  const call = { start, end: close + 1, id: "", pack: "", name: "" };
2493
- const args = splitTopLevel(src.slice(i + 1, close), ",");
2494
- const id = stringLiteralValue(args[0] ?? "");
4506
+ const args = splitTopLevel2(src.slice(i + 1, close), ",");
4507
+ const id = stringLiteralValue2(args[0] ?? "");
2495
4508
  if (id === null) {
2496
4509
  call.error = `assetIcon(...) needs a string-literal id like "lucide:bell" (got \`${(args[0] ?? "").trim() || "()"}\`).`;
2497
4510
  return call;
@@ -2522,8 +4535,8 @@ var extractColor = (optsText) => {
2522
4535
  if (!optsText.startsWith("{") || !optsText.endsWith("}")) {
2523
4536
  return { error: `assetIcon(id, opts): opts must be an object literal like { color: "#5b8cff" }.` };
2524
4537
  }
2525
- for (const prop of splitTopLevel(optsText.slice(1, -1), ",")) {
2526
- const kv = splitTopLevel(prop, ":");
4538
+ for (const prop of splitTopLevel2(optsText.slice(1, -1), ",")) {
4539
+ const kv = splitTopLevel2(prop, ":");
2527
4540
  if (kv.length < 2)
2528
4541
  continue;
2529
4542
  const key = kv[0].trim().replace(/^["'`]|["'`]$/g, "");
@@ -2532,7 +4545,7 @@ var extractColor = (optsText) => {
2532
4545
  const raw = kv.slice(1).join(":").trim();
2533
4546
  if (!raw)
2534
4547
  continue;
2535
- const literal = stringLiteralValue(raw);
4548
+ const literal = stringLiteralValue2(raw);
2536
4549
  return literal !== null ? { bake: literal } : { tint: raw };
2537
4550
  }
2538
4551
  return {};
@@ -2553,34 +4566,34 @@ var iconExpr = (svg, call) => {
2553
4566
  return call.tint ? `Object.assign(${src}, { tintColor: (${call.tint}) })` : src;
2554
4567
  };
2555
4568
  var escapeTemplate = (s) => s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
2556
- var isHttp = (s) => /^https?:/i.test(s);
2557
- var sha12 = (s) => createHash2("sha256").update(s).digest("hex").slice(0, 12);
2558
- var registryHome = (registry) => registry.replace(/\/v\d+$/, "");
4569
+ var isHttp2 = (s) => /^https?:/i.test(s);
4570
+ var sha122 = (s) => createHash3("sha256").update(s).digest("hex").slice(0, 12);
4571
+ var registryHome2 = (registry) => registry.replace(/\/v\d+$/, "");
2559
4572
  var readText = async (registry, rel) => {
2560
- if (isHttp(registry)) {
4573
+ if (isHttp2(registry)) {
2561
4574
  const res = await fetch(`${registry}/${rel}`);
2562
4575
  return res.ok ? await res.text() : null;
2563
4576
  }
2564
4577
  try {
2565
- return readFileSync10(join12(registry, rel), "utf8");
4578
+ return readFileSync12(join14(registry, rel), "utf8");
2566
4579
  } catch {
2567
4580
  return null;
2568
4581
  }
2569
4582
  };
2570
- var readLock = (path) => {
4583
+ var readLock2 = (path) => {
2571
4584
  try {
2572
- const lock = JSON.parse(readFileSync10(path, "utf8"));
4585
+ const lock = JSON.parse(readFileSync12(path, "utf8"));
2573
4586
  if (lock && typeof lock === "object" && lock.icons && lock.packs)
2574
4587
  return lock;
2575
4588
  } catch {}
2576
4589
  return null;
2577
4590
  };
2578
- var serializeLock = (lock) => {
4591
+ var serializeLock2 = (lock) => {
2579
4592
  const sortKeys = (rec) => Object.fromEntries(Object.keys(rec).sort().map((k) => [k, rec[k]]));
2580
4593
  return JSON.stringify({ registry: lock.registry, icons: sortKeys(lock.icons), packs: sortKeys(lock.packs) }, null, 2) + `
2581
4594
  `;
2582
4595
  };
2583
- var renderNotices = (lock) => {
4596
+ var renderNotices2 = (lock) => {
2584
4597
  const packs = [...new Set(Object.values(lock.icons).map((i) => i.pack))].sort();
2585
4598
  const lines = [
2586
4599
  "# Icon attribution & notices",
@@ -2605,29 +4618,29 @@ var renderNotices = (lock) => {
2605
4618
  return lines.join(`
2606
4619
  `);
2607
4620
  };
2608
- var writeIfChanged = (path, text) => {
2609
- const current = existsSync10(path) ? safeRead(path) : null;
4621
+ var writeIfChanged2 = (path, text) => {
4622
+ const current = existsSync12(path) ? safeRead(path) : null;
2610
4623
  if (current === text)
2611
4624
  return false;
2612
- writeFileSync8(path, text);
4625
+ writeFileSync10(path, text);
2613
4626
  return true;
2614
4627
  };
2615
4628
  var safeRead = (path) => {
2616
4629
  try {
2617
- return readFileSync10(path, "utf8");
4630
+ return readFileSync12(path, "utf8");
2618
4631
  } catch {
2619
4632
  return null;
2620
4633
  }
2621
4634
  };
2622
4635
  var vendorIcons = async (designDir, ids, opts = {}) => {
2623
4636
  const registry = (opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "");
2624
- const lockPath = join12(designDir, "icons.lock.json");
2625
- const lock = readLock(lockPath) ?? { registry, icons: {}, packs: {} };
4637
+ const lockPath = join14(designDir, "icons.lock.json");
4638
+ const lock = readLock2(lockPath) ?? { registry, icons: {}, packs: {} };
2626
4639
  const svgById = new Map;
2627
4640
  const missing = [];
2628
4641
  for (const id of ids) {
2629
4642
  const li = lock.icons[id];
2630
- const cached = li && safeRead(join12(designDir, li.path));
4643
+ const cached = li && safeRead(join14(designDir, li.path));
2631
4644
  if (cached)
2632
4645
  svgById.set(id, cached);
2633
4646
  else
@@ -2643,23 +4656,23 @@ var vendorIcons = async (designDir, ids, opts = {}) => {
2643
4656
  const [, pack, name] = m;
2644
4657
  const lic = licenses[pack];
2645
4658
  if (!lic)
2646
- throw new Error(`assetIcon: unknown icon pack "${pack}" (in "${id}"). Browse packs at ${registryHome(registry)}.`);
4659
+ throw new Error(`assetIcon: unknown icon pack "${pack}" (in "${id}"). Browse packs at ${registryHome2(registry)}.`);
2647
4660
  const svg = await readText(registry, `${pack}/${name}.svg`);
2648
4661
  if (svg === null)
2649
- throw new Error(`assetIcon: icon "${id}" not found in the registry. Check the name at ${registryHome(registry)}.`);
4662
+ throw new Error(`assetIcon: icon "${id}" not found in the registry. Check the name at ${registryHome2(registry)}.`);
2650
4663
  const rel = `assets/icons/${pack}/${name}.svg`;
2651
- const abs = join12(designDir, rel);
2652
- mkdirSync7(dirname7(abs), { recursive: true });
2653
- writeFileSync8(abs, svg);
2654
- lock.icons[id] = { pack, name, hash: sha12(svg), spdx: lic.spdx, path: rel };
4664
+ const abs = join14(designDir, rel);
4665
+ mkdirSync9(dirname9(abs), { recursive: true });
4666
+ writeFileSync10(abs, svg);
4667
+ lock.icons[id] = { pack, name, hash: sha122(svg), spdx: lic.spdx, path: rel };
2655
4668
  lock.packs[pack] = { spdx: lic.spdx, title: lic.title, url: lic.url, attribution: lic.attribution, modifiedNotice: lic.modifiedNotice };
2656
4669
  svgById.set(id, svg);
2657
4670
  }
2658
4671
  lock.registry = registry;
2659
- writeIfChanged(lockPath, serializeLock(lock));
4672
+ writeIfChanged2(lockPath, serializeLock2(lock));
2660
4673
  }
2661
4674
  if (Object.keys(lock.icons).length)
2662
- writeIfChanged(join12(designDir, "ICON-NOTICES.md"), renderNotices(lock));
4675
+ writeIfChanged2(join14(designDir, "ICON-NOTICES.md"), renderNotices2(lock));
2663
4676
  return svgById;
2664
4677
  };
2665
4678
  var applyAssetIcons = async (entries, designDir, opts = {}) => {
@@ -2683,7 +4696,7 @@ var compileDesignScreen = async (opts) => {
2683
4696
  const entries = [];
2684
4697
  const scan = scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest?.files ?? {})) });
2685
4698
  for (const file of scan.files) {
2686
- const type = manifest?.files[file.path]?.type ?? classifyKind(basename4(file.path));
4699
+ const type = manifest?.files[file.path]?.type ?? classifyKind(basename5(file.path));
2687
4700
  if (type === "resource") {
2688
4701
  entries.push({
2689
4702
  path: file.path,
@@ -2694,7 +4707,7 @@ var compileDesignScreen = async (opts) => {
2694
4707
  } else if (type === "shader") {
2695
4708
  entries.push({ path: file.path, type, shaders: [] });
2696
4709
  } else {
2697
- entries.push({ path: file.path, type, text: readFileSync11(file.absPath, "utf8") });
4710
+ entries.push({ path: file.path, type, text: readFileSync13(file.absPath, "utf8") });
2698
4711
  }
2699
4712
  }
2700
4713
  const invoke = state !== undefined ? `screen(${JSON.stringify(state)})` : "screen()";
@@ -2708,6 +4721,18 @@ var compileDesignScreen = async (opts) => {
2708
4721
  `
2709
4722
  });
2710
4723
  await applyAssetIcons(entries, designDir);
4724
+ await applyProjectFonts(entries, {
4725
+ mode: "vendor",
4726
+ root,
4727
+ vendorDir: designDir,
4728
+ vendorPrefix: designKey,
4729
+ makeResourceEntry: (path, absPath) => ({
4730
+ path,
4731
+ type: "resource",
4732
+ fileSrc: opts.assets === "disk" ? encodeURIComponent(absPath) : encodeURI(path),
4733
+ absPath
4734
+ })
4735
+ });
2711
4736
  return compileProject2({
2712
4737
  entries,
2713
4738
  name: opts.name,
@@ -2754,10 +4779,10 @@ var openBrowser = (url) => {
2754
4779
  };
2755
4780
  var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser) => {
2756
4781
  const state = randomBytes(16).toString("hex");
2757
- let resolve4;
4782
+ let resolve5;
2758
4783
  let reject;
2759
4784
  const tokenPromise = new Promise((res, rej) => {
2760
- resolve4 = res;
4785
+ resolve5 = res;
2761
4786
  reject = rej;
2762
4787
  });
2763
4788
  const server = createServer((req, res) => {
@@ -2775,7 +4800,7 @@ var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser
2775
4800
  }
2776
4801
  res.writeHead(200, { "content-type": "text/html" });
2777
4802
  res.end(SUCCESS_HTML);
2778
- resolve4(token);
4803
+ resolve5(token);
2779
4804
  });
2780
4805
  await new Promise((res, rej) => {
2781
4806
  server.once("error", rej);
@@ -2839,6 +4864,9 @@ Behaviour that isn't visible in a single screen — validation, permissions, not
2839
4864
  cases — and the decisions you've made and why, so they aren't re-litigated later.
2840
4865
  `;
2841
4866
  var TOKENS_TS = `// Design tokens shared by every screen. Colors are hex strings (the engine parses hex only).
4867
+ // App font: one theme() call here sets the default text font everywhere. font("id") installs a
4868
+ // registry family (vendored at compile time) and returns its name — no loading code needed.
4869
+ // theme({ fontFamily: font("manrope") })
2842
4870
  export const colors = {
2843
4871
  bg: "#101114",
2844
4872
  card: "#1c1e24",
@@ -2852,6 +4880,57 @@ export const spacing = { xs: 4, sm: 8, md: 16, lg: 24 }
2852
4880
 
2853
4881
  export const radius = { sm: 8, md: 12, lg: 20 }
2854
4882
  `;
4883
+ var TABS_TS = `// The app's tab bar, declared ONCE — the design canvas discovers it from this file.
4884
+ //
4885
+ // export const mainTabs = defineTabs({
4886
+ // home: { label: "Home", icon: assetIcon("lucide:house") },
4887
+ // news: { label: "News", icon: assetIcon("lucide:newspaper") },
4888
+ // })
4889
+ //
4890
+ // The KEYS are screen ids — each tab's root screen (screens/<key>.ts), in tab order, literals only.
4891
+ // Every screen that shows the bar mounts it with a literal tab id — tab roots AND pushed details
4892
+ // that keep the bar:
4893
+ //
4894
+ // export default () => mainTabs.screen("news", [ ...content ])
4895
+ //
4896
+ // A screen that doesn't mount it is a full-screen push (bar hidden). Never draw meta.json edges
4897
+ // between tab screens — the bar already navigates; the board renders it as a rail + lanes.
4898
+ // Restyle the bar below freely: only the defineTabs({ keys }) declaration and the
4899
+ // .screen("<tab>", …) / .bar("<tab>") call shapes are parsed.
4900
+
4901
+ type TabDef = { label: string, icon?: { svg: string, tintColor: string | null } }
4902
+
4903
+ // Theme-variable driven — theme({ primaryColor, mutedColor, tabbarBg, screenBg }) anywhere
4904
+ // restyles every screen's bar at once; the fallbacks keep an untouched scaffold looking right.
4905
+ const ACTIVE = "var(--primaryColor, #5b8cff)"
4906
+ const INACTIVE = "var(--mutedColor, #8a919e)"
4907
+ const BAR_BG = "var(--tabbarBg, #15171c)"
4908
+ const SCREEN_BG = "var(--screenBg, #101114)"
4909
+
4910
+ export const defineTabs = <T extends Record<string, TabDef>>(tabs: T) => {
4911
+ const ids = Object.keys(tabs) as (keyof T & string)[]
4912
+
4913
+ /** The bar alone — mount it yourself when the screen owns its whole layout. */
4914
+ const bar = (active: keyof T & string) =>
4915
+ UIRow(ids.map((id) => {
4916
+ const color = id === active ? ACTIVE : INACTIVE
4917
+ return UIButton([
4918
+ ...(tabs[id].icon ? [UIImage(tabs[id].icon!).style({ width: 22, height: 22, tintColor: color })] : []),
4919
+ UIText(tabs[id].label).style({ fontSize: 10, color }),
4920
+ ]).style({ name: \`tab-\${id}\`, flexDirection: "column", gap: 3, flexGrow: 1, flexBasis: 0, pt: 8, pb: 6 })
4921
+ })).style({ bgColor: BAR_BG, pb: "safe-bottom" })
4922
+
4923
+ /** A screen that sits ON the bar (the common case): [ content, bar ] — the same column the real
4924
+ * app composes as [ UIPager, bar ]. */
4925
+ const screen = (active: keyof T & string, children: UINodeChild[]) =>
4926
+ UIScreen([
4927
+ UIColumn(children).style({ flexGrow: 1, p: 16, pt: "safe-top" }),
4928
+ bar(active),
4929
+ ]).style({ bgColor: SCREEN_BG })
4930
+
4931
+ return { ids, bar, screen }
4932
+ }
4933
+ `;
2855
4934
  var HOME_SCREEN_TS = `import { colors, spacing, radius } from "../shared/tokens"
2856
4935
 
2857
4936
  // Mock data: the domain shown on realistic values. Inline, per screen.
@@ -2930,6 +5009,12 @@ positions or descriptions.
2930
5009
 
2931
5010
  - An edge's optional \`activator\` names the element (its \`name\` style option) that triggers the
2932
5011
  transition. Give a \`name\` to interactive elements an edge refers to — nothing else needs names.
5012
+ - **Edges are forward intent only.** Never draw back/dismiss/logout edges — the navigation stack
5013
+ implies return paths. And never draw edges between tab screens — the tab bar already navigates
5014
+ (see "The tab bar" below); the board flags bar-implied edges for deletion.
5015
+ - **An edge from a screen to itself** (\`"from": "home", "to": "home@empty"\`) is a *state link*: the
5016
+ board draws it as a chip on the activator element (clicking it switches the tile's state), never
5017
+ as an arrow. Use it to record "tapping X puts this screen into state Y".
2933
5018
  - **Group each screen** you create by its user-facing area — \`"group": "Onboarding"\` — 2–6 screens
2934
5019
  per group (Onboarding, Checkout, Settings…). Groups render as labeled regions and \`arrange\` lays
2935
5020
  each out as its own sub-flow, so grouping keeps big boards legible. Semantic clustering is your
@@ -2977,6 +5062,25 @@ keep in the chat.
2977
5062
  typing a component's return value or a container's children — with no imports. Trust the types;
2978
5063
  don't fall back to \`any\`.
2979
5064
 
5065
+ ## The tab bar
5066
+
5067
+ Most apps have one — declare it ONCE, in source, and never draw edges for it:
5068
+
5069
+ - \`shared/tabs.ts\` ships a \`defineTabs\` helper. Declare the bar there:
5070
+ \`export const mainTabs = defineTabs({ home: { label: "Home", icon: assetIcon("lucide:house") }, … })\`
5071
+ — the **keys are screen ids** (each tab's root screen, in tab order), literals only.
5072
+ - Every screen that shows the bar mounts it with a **literal** tab id:
5073
+ \`export default () => mainTabs.screen("news", [ …content ])\` — tab roots AND pushed detail
5074
+ screens that keep the bar (the news lane's article screen mounts \`"news"\` too). A screen that
5075
+ doesn't mount it is a full-screen push (bar hidden), e.g. a photo viewer.
5076
+ - The canvas discovers all of this from the source (like screen states) and draws the bar as a
5077
+ **rail + lane bands** — tab switching needs NO edges, and \`design_state\` serves the discovered
5078
+ \`hubs\`. In the live prototype, tapping a tab button jumps the board to that tab's root screen.
5079
+ - Style through theme variables: the bar reads \`var(--primaryColor)\`, \`var(--mutedColor)\`,
5080
+ \`var(--tabbarBg)\`, \`var(--screenBg)\` (with fallbacks) — one \`theme({ primaryColor: … })\` call
5081
+ restyles it app-wide. You may restyle \`shared/tabs.ts\` itself; keep the \`defineTabs({ … })\`
5082
+ declaration and \`.screen("<tab>", …)\` call shapes intact (the canvas parses them).
5083
+
2980
5084
  ## Screen states (variants)
2981
5085
 
2982
5086
  A screen often has more than one look — a list vs. its empty state, a form vs. its error state.
@@ -2984,10 +5088,9 @@ Instead of a second file per variant, make the default export a **function of a
2984
5088
  type is a string-literal union** — the union IS the state list (no separate schema):
2985
5089
 
2986
5090
  \`\`\`ts
2987
- export default (state: "default" | "empty" = "default") => UIScreen([
5091
+ export default (state: "default" | "empty" = "default") => mainTabs.screen("home", [
2988
5092
  Header(child),
2989
5093
  state === "empty" ? EmptyState("Nothing here yet") : UIScrollable(items.map(Card)),
2990
- TabBar(tabs, "home"),
2991
5094
  ])
2992
5095
  \`\`\`
2993
5096
 
@@ -3005,6 +5108,35 @@ export default (state: "default" | "empty" = "default") => UIScreen([
3005
5108
  \`"home@empty"\`) — still one node per screen. \`lecodes design check\` errors on an edge pointing at
3006
5109
  a state the screen doesn't declare.
3007
5110
 
5111
+ ## Overlays (sheets & dialogs)
5112
+
5113
+ A bottom sheet or confirm dialog is a **\`UIWidget\`**, not an absolutely-positioned child of the
5114
+ screen. Its \`overlayColor\` is the scrim — a real modal layer: it blocks everything beneath it
5115
+ (taps AND the canvas's hover/hints), and a tap on it fires \`onOverlayTap\`. \`show()\` it from the
5116
+ state branch instead of embedding it:
5117
+
5118
+ \`\`\`ts
5119
+ const FilterSheet = () => {
5120
+ const sheet = UIWidget(
5121
+ { left: 0, right: 0, bottom: 0, p: 16, gap: 8, overlayColor: "#0008" },
5122
+ [ SheetCard([ … ]), PrimaryButton("Apply", { name: "apply" }) ],
5123
+ )
5124
+ return sheet.onOverlayTap(() => sheet.hide())
5125
+ }
5126
+
5127
+ export default (state: "default" | "filters" = "default") => {
5128
+ if (state === "filters") FilterSheet().show()
5129
+ return UIScreen([ … ])
5130
+ }
5131
+ \`\`\`
5132
+
5133
+ - Size the widget box to the **content only** (bottom-anchor a sheet, or center a dialog card
5134
+ inside a full-inset box) — the scrim is the \`overlayColor\`, painted full-screen behind it.
5135
+ - A tap lands **outside the widget box** → \`onOverlayTap\` (dismiss a sheet there). A confirm
5136
+ dialog that demands an explicit choice uses a full-inset box and no \`onOverlayTap\`.
5137
+ - Headless snapshots render widgets as \`overlays\` in the JSON and composite them in the PNG,
5138
+ and the edge \`activator\` names on widget elements (\`"apply"\`, \`"confirm"\`) work as usual.
5139
+
3008
5140
  ## Icons
3009
5141
 
3010
5142
  Use real icons instead of emoji. \`assetIcon("pack:name")\` is a **compile-time macro**: it vendors
@@ -3027,6 +5159,28 @@ UIImage(assetIcon("lucide:check")).style({ width: 20, height: 20, tintColor: col
3027
5159
  (attribution) — **commit these**; they make rebuilds offline. An unknown \`pack:name\` is a clear
3028
5160
  compile error.
3029
5161
 
5162
+ ## Fonts
5163
+
5164
+ Real typography sells a design. \`font("id")\` installs a font from the registry AND returns its
5165
+ family name — no loading code, no awaiting, ever:
5166
+
5167
+ \`\`\`ts
5168
+ theme({ fontFamily: font("manrope") }) // app font, in shared/tokens.ts
5169
+ UIText("Hero").style({ fontFamily: font("unbounded"), fontSize: 34 }) // display accents per node
5170
+ font("ibm-plex-sans", { weights: [400, 600] }) // pick exact weights
5171
+ \`\`\`
5172
+
5173
+ - Registry ids (all OFL, cyrillic-capable unless noted):
5174
+ sans \`inter manrope golos-text onest rubik nunito montserrat ibm-plex-sans pt-sans jost exo-2\`;
5175
+ display \`unbounded russo-one oswald comfortaa space-grotesk\` (latin only);
5176
+ serif \`playfair-display lora pt-serif cormorant literata\`;
5177
+ mono \`jetbrains-mono fira-code ibm-plex-mono\`; handwriting \`caveat pacifico\`.
5178
+ - Default weights are ~400/500/700 — install the weights you use (\`fontWeight: 600\` wants a 600
5179
+ face). The id must be a **string literal**; an unknown one is a clear compile error.
5180
+ - Your own file works too: \`font("./fonts/Brand.ttf")\` — family/weight are read from the file.
5181
+ - Vendored faces land in \`assets/fonts/\`, tracked by \`fonts.lock.json\` + \`FONT-NOTICES.md\` —
5182
+ **commit these**; they make rebuilds offline and they're what the platform board renders from.
5183
+
3030
5184
  ## See what you built
3031
5185
 
3032
5186
  The developer watches the canvas live (screens hot-reload on save). To see a screen yourself:
@@ -3077,6 +5231,15 @@ var DESIGN_MACROS_DTS = `// Generated by \`lecodes design init\`. Globals the de
3077
5231
  * string literal (resolved, and the SVG vendored, at compile time).
3078
5232
  */
3079
5233
  declare function assetIcon(id: string, opts?: { color?: string }): { readonly svg: string, tintColor: string | null }
5234
+
5235
+ /**
5236
+ * Install a font and get its family name back — using a font IS declaring it. A registry id
5237
+ * (\`font("manrope")\`) vendors faces from the font registry at compile time ({ weights, italic }
5238
+ * narrows the set); a project path (\`font("./fonts/Brand.ttf")\`) reads family/weight/italic from
5239
+ * the file itself. The argument must be a string literal. No awaiting — pass the result straight
5240
+ * to \`theme({ fontFamily: font("manrope") })\` or a \`fontFamily\` style.
5241
+ */
5242
+ declare function font(id: string, opts?: { weights?: number[], italic?: boolean }): string
3080
5243
  `;
3081
5244
  var README_HINT = `# design/
3082
5245
 
@@ -3101,33 +5264,33 @@ var getContext = (args) => {
3101
5264
  return {
3102
5265
  root,
3103
5266
  manifest,
3104
- name: manifest?.name ?? basename5(root),
5267
+ name: manifest?.name ?? basename6(root),
3105
5268
  dirName,
3106
- designDir: join13(root, dirName),
5269
+ designDir: join15(root, dirName),
3107
5270
  designKey: "/" + dirName.replace(/\\/g, "/")
3108
5271
  };
3109
5272
  };
3110
5273
  var listScreens = (designDir) => {
3111
- const dir = join13(designDir, "screens");
3112
- if (!existsSync11(dir))
5274
+ const dir = join15(designDir, "screens");
5275
+ if (!existsSync13(dir))
3113
5276
  return [];
3114
- return readdirSync6(dir).filter((f) => f.endsWith(".ts") && !f.startsWith(".")).map((f) => f.slice(0, -3)).sort();
5277
+ return readdirSync7(dir).filter((f2) => f2.endsWith(".ts") && !f2.startsWith(".")).map((f2) => f2.slice(0, -3)).sort();
3115
5278
  };
3116
5279
  var readMetaSizes = (designDir) => {
3117
5280
  try {
3118
- const meta = JSON.parse(readFileSync12(join13(designDir, "meta.json"), "utf8"));
5281
+ const meta = JSON.parse(readFileSync14(join15(designDir, "meta.json"), "utf8"));
3119
5282
  return meta?.screens ?? {};
3120
5283
  } catch {
3121
5284
  return {};
3122
5285
  }
3123
5286
  };
3124
5287
  var writeMcpConfig = (ctx, port) => {
3125
- const path = join13(ctx.root, ".mcp.json");
5288
+ const path = join15(ctx.root, ".mcp.json");
3126
5289
  const entry = { type: "http", url: `http://localhost:${port}/mcp` };
3127
5290
  let config = {};
3128
- if (existsSync11(path)) {
5291
+ if (existsSync13(path)) {
3129
5292
  try {
3130
- config = JSON.parse(readFileSync12(path, "utf8"));
5293
+ config = JSON.parse(readFileSync14(path, "utf8"));
3131
5294
  } catch {
3132
5295
  warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-design server by hand.");
3133
5296
  return;
@@ -3140,7 +5303,7 @@ var writeMcpConfig = (ctx, port) => {
3140
5303
  return;
3141
5304
  }
3142
5305
  config.mcpServers["lecodes-design"] = entry;
3143
- writeFileSync9(path, JSON.stringify(config, null, 2) + `
5306
+ writeFileSync11(path, JSON.stringify(config, null, 2) + `
3144
5307
  `);
3145
5308
  log(`${c.green(existing ? "updated" : "created")} .mcp.json (lecodes-design MCP → ${entry.url})`);
3146
5309
  };
@@ -3150,25 +5313,26 @@ var init = async (ctx, args) => {
3150
5313
  ["spec.md", SPEC_MD],
3151
5314
  ["README.md", README_HINT],
3152
5315
  ["CLAUDE.md", CLAUDE_MD],
3153
- [join13("shared", "tokens.ts"), TOKENS_TS],
3154
- [join13("screens", "home.ts"), HOME_SCREEN_TS]
5316
+ [join15("shared", "tokens.ts"), TOKENS_TS],
5317
+ [join15("shared", "tabs.ts"), TABS_TS],
5318
+ [join15("screens", "home.ts"), HOME_SCREEN_TS]
3155
5319
  ];
3156
- mkdirSync8(join13(ctx.designDir, "screens"), { recursive: true });
3157
- mkdirSync8(join13(ctx.designDir, "shared"), { recursive: true });
5320
+ mkdirSync10(join15(ctx.designDir, "screens"), { recursive: true });
5321
+ mkdirSync10(join15(ctx.designDir, "shared"), { recursive: true });
3158
5322
  let created = 0;
3159
5323
  for (const [rel, content] of files) {
3160
- const abs = join13(ctx.designDir, rel);
3161
- if (existsSync11(abs)) {
5324
+ const abs = join15(ctx.designDir, rel);
5325
+ if (existsSync13(abs)) {
3162
5326
  note(`kept ${ctx.dirName}/${rel.replace(/\\/g, "/")}`);
3163
5327
  continue;
3164
5328
  }
3165
- writeFileSync9(abs, content);
5329
+ writeFileSync11(abs, content);
3166
5330
  log(`${c.green("created")} ${ctx.dirName}/${rel.replace(/\\/g, "/")}`);
3167
5331
  created++;
3168
5332
  }
3169
- const ignorePath = join13(ctx.root, ".lecodesignore");
5333
+ const ignorePath = join15(ctx.root, ".lecodesignore");
3170
5334
  const ignoreLine = `${ctx.dirName}/.snapshots/`;
3171
- const current = existsSync11(ignorePath) ? readFileSync12(ignorePath, "utf8") : "";
5335
+ const current = existsSync13(ignorePath) ? readFileSync14(ignorePath, "utf8") : "";
3172
5336
  if (!current.split(/\r?\n/).includes(ignoreLine)) {
3173
5337
  appendFileSync(ignorePath, (current && !current.endsWith(`
3174
5338
  `) ? `
@@ -3179,8 +5343,8 @@ var init = async (ctx, args) => {
3179
5343
  writeMcpConfig(ctx, Number(flagStr(args, "port")) || DEFAULT_PORT);
3180
5344
  const wroteTypes = materializeTypesLocal(ctx.designDir);
3181
5345
  writeTsconfig(ctx.designDir);
3182
- mkdirSync8(join13(ctx.designDir, ".lecodes", "types"), { recursive: true });
3183
- writeFileSync9(join13(ctx.designDir, ".lecodes", "types", "design-macros.d.ts"), DESIGN_MACROS_DTS);
5346
+ mkdirSync10(join15(ctx.designDir, ".lecodes", "types"), { recursive: true });
5347
+ writeFileSync11(join15(ctx.designDir, ".lecodes", "types", "design-macros.d.ts"), DESIGN_MACROS_DTS);
3184
5348
  log(`${c.green("created")} ${ctx.dirName}/tsconfig.json${wroteTypes ? ` + ${ctx.dirName}/.lecodes/types/` : ""} (IDE types)`);
3185
5349
  if (!wroteTypes)
3186
5350
  warnErr("Bundled SDK types not found — tsconfig written, but globals may show unresolved until the CLI is rebuilt with vendored types.");
@@ -3229,16 +5393,16 @@ var snapshot = async (ctx, args) => {
3229
5393
  killer.unref?.();
3230
5394
  const png = flagBool(args, "png") || flagStr(args, "png") !== undefined;
3231
5395
  const sizes = readMetaSizes(ctx.designDir);
3232
- const outDir = flagStr(args, "out-dir") ?? join13(ctx.designDir, ".snapshots");
5396
+ const outDir = flagStr(args, "out-dir") ?? join15(ctx.designDir, ".snapshots");
3233
5397
  const single = targets.length === 1 && requested.length === 1 && !png && !allStates && !flagStr(args, "out-dir");
3234
5398
  const onConsole = flagBool(args, "logs") ? (level, text) => logErr(`${c.dim(`[${level}]`)} ${text}`) : undefined;
3235
5399
  if (!single)
3236
- mkdirSync8(outDir, { recursive: true });
5400
+ mkdirSync10(outDir, { recursive: true });
3237
5401
  for (const id of targets) {
3238
5402
  const [width, height] = sizes[id]?.size ?? [390, 844];
3239
5403
  let plan = [{ state: undefined, bases: [id] }];
3240
5404
  if (discover) {
3241
- const { states, canonical } = discover(readFileSync12(join13(ctx.designDir, "screens", `${id}.ts`), "utf8"));
5405
+ const { states, canonical } = discover(readFileSync14(join15(ctx.designDir, "screens", `${id}.ts`), "utf8"));
3242
5406
  if (stateFlag !== undefined) {
3243
5407
  if (!states.includes(stateFlag)) {
3244
5408
  if (requested.length === 1)
@@ -3279,18 +5443,18 @@ var snapshot = async (ctx, args) => {
3279
5443
  for (const w of pngResult.warnings)
3280
5444
  warnErr(`${tag}: ${w}`);
3281
5445
  for (const base of bases) {
3282
- writeFileSync9(join13(outDir, `${base}.json`), result.text);
3283
- log(`${c.green("✓")} ${tag} → ${join13(outDir, `${base}.json`)}`);
5446
+ writeFileSync11(join15(outDir, `${base}.json`), result.text);
5447
+ log(`${c.green("✓")} ${tag} → ${join15(outDir, `${base}.json`)}`);
3284
5448
  if (pngResult) {
3285
- writeFileSync9(join13(outDir, `${base}.png`), pngResult.png);
3286
- log(`${c.green("✓")} ${tag} → ${join13(outDir, `${base}.png`)}`);
5449
+ writeFileSync11(join15(outDir, `${base}.png`), pngResult.png);
5450
+ log(`${c.green("✓")} ${tag} → ${join15(outDir, `${base}.png`)}`);
3287
5451
  }
3288
5452
  }
3289
5453
  }
3290
5454
  }
3291
5455
  };
3292
5456
  var serve = async (ctx, args) => {
3293
- if (!existsSync11(ctx.designDir)) {
5457
+ if (!existsSync13(ctx.designDir)) {
3294
5458
  throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`);
3295
5459
  }
3296
5460
  let designServer;
@@ -3363,7 +5527,7 @@ var serve = async (ctx, args) => {
3363
5527
  openBrowser(server.url);
3364
5528
  };
3365
5529
  var arrange = async (ctx, args) => {
3366
- if (!existsSync11(ctx.designDir)) {
5530
+ if (!existsSync13(ctx.designDir)) {
3367
5531
  throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`);
3368
5532
  }
3369
5533
  let designServer;
@@ -3399,7 +5563,7 @@ var share = async (ctx, args) => {
3399
5563
  note("Turn it off with: lecodes design share --off");
3400
5564
  };
3401
5565
  var check = async (ctx, args) => {
3402
- if (!existsSync11(ctx.designDir)) {
5566
+ if (!existsSync13(ctx.designDir)) {
3403
5567
  throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`);
3404
5568
  }
3405
5569
  let designServer;
@@ -3436,6 +5600,14 @@ var check = async (ctx, args) => {
3436
5600
  log(` ${c.yellow("!")} ${s.id}: ${s.statesWarning}`);
3437
5601
  log("");
3438
5602
  }
5603
+ if (state.hubs.length) {
5604
+ log(c.bold("Tab bars"));
5605
+ for (const h of state.hubs) {
5606
+ const mounted = Object.keys(h.lanes).length;
5607
+ log(` ${h.name} (${h.file}): ${h.tabs.join(" · ")} — ${mounted} screen${mounted === 1 ? "" : "s"} on the bar`);
5608
+ }
5609
+ log("");
5610
+ }
3439
5611
  const hard = state.warnings.length;
3440
5612
  if (hard === 0) {
3441
5613
  const n = state.screens.length;
@@ -3464,15 +5636,15 @@ var design = async (args) => {
3464
5636
  };
3465
5637
 
3466
5638
  // src/commands/dev.ts
3467
- import { existsSync as existsSync13, mkdirSync as mkdirSync9, readFileSync as readFileSync13, watch, writeFileSync as writeFileSync10 } from "node:fs";
5639
+ import { existsSync as existsSync15, mkdirSync as mkdirSync11, readFileSync as readFileSync15, watch, writeFileSync as writeFileSync12 } from "node:fs";
3468
5640
  import { randomBytes as randomBytes2 } from "node:crypto";
3469
5641
  import { networkInterfaces } from "node:os";
3470
- import { basename as basename6, join as join15 } from "node:path";
5642
+ import { basename as basename7, join as join17 } from "node:path";
3471
5643
 
3472
5644
  // src/dev/devServer.ts
3473
5645
  import { createServer as createServer2 } from "node:http";
3474
- import { createReadStream, existsSync as existsSync12, statSync as statSync2 } from "node:fs";
3475
- import { join as join14 } from "node:path";
5646
+ import { createReadStream, existsSync as existsSync14, statSync as statSync3 } from "node:fs";
5647
+ import { join as join16 } from "node:path";
3476
5648
 
3477
5649
  // src/dev/clientTemplates.ts
3478
5650
  var DEV_SCREEN_TS = `const title = "LeCodes Dev"
@@ -3587,9 +5759,9 @@ var renderPrelude = (p) => DEV_PRELUDE_JS.split("__DEV_ORIGIN__").join(p.origin)
3587
5759
  var renderDevScreen = (name) => DEV_SCREEN_TS.split("__DEV_NAME__").join(JSON.stringify(name));
3588
5760
 
3589
5761
  // src/dev/wsServer.ts
3590
- import { createHash as createHash3 } from "node:crypto";
5762
+ import { createHash as createHash4 } from "node:crypto";
3591
5763
  var WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
3592
- var acceptKey = (key) => createHash3("sha1").update(key + WS_GUID).digest("base64");
5764
+ var acceptKey = (key) => createHash4("sha1").update(key + WS_GUID).digest("base64");
3593
5765
  var encodeTextFrame = (text) => {
3594
5766
  const payload = Buffer.from(text, "utf8");
3595
5767
  const len = payload.length;
@@ -3736,14 +5908,14 @@ var sendText = (res, status, type, body) => {
3736
5908
  res.end(body);
3737
5909
  };
3738
5910
  var sendJson = (res, status, body) => sendText(res, status, "application/json", JSON.stringify(body));
3739
- var readBody = (req) => new Promise((resolve4) => {
5911
+ var readBody = (req) => new Promise((resolve5) => {
3740
5912
  let body = "";
3741
5913
  req.on("data", (c2) => {
3742
5914
  if (body.length < 256 * 1024)
3743
5915
  body += c2.toString("utf8");
3744
5916
  });
3745
- req.on("end", () => resolve4(body));
3746
- req.on("error", () => resolve4(body));
5917
+ req.on("end", () => resolve5(body));
5918
+ req.on("error", () => resolve5(body));
3747
5919
  });
3748
5920
  var startDevServer = async (options) => {
3749
5921
  const { root, manifest, name, origin, token, entryOverride } = options;
@@ -3756,7 +5928,15 @@ var startDevServer = async (options) => {
3756
5928
  const compileOnce = async () => {
3757
5929
  try {
3758
5930
  const { entries, warnings } = collectLocalEntries(root, manifest, `${base}/assets`);
3759
- const js = await compileProject2({ entries, name, publicUrl: origin, entryOverride, header: true });
5931
+ const fontsHeader = await applyProjectFonts(entries, {
5932
+ mode: "vendor",
5933
+ root,
5934
+ vendorDir: root,
5935
+ vendorPrefix: "",
5936
+ publicUrl: origin,
5937
+ makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: `${base}/assets` + encodeURI(path), absPath })
5938
+ });
5939
+ const js = await compileProject2({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader });
3760
5940
  const changed = bundleJs === null || stripVolatile(js) !== stripVolatile(bundleJs);
3761
5941
  if (changed) {
3762
5942
  bundleJs = js;
@@ -3828,8 +6008,8 @@ var startDevServer = async (options) => {
3828
6008
  } catch {
3829
6009
  return sendText(res, 400, "text/plain", "Bad path");
3830
6010
  }
3831
- const abs = join14(root, key.slice(1));
3832
- if (!existsSync12(abs) || !statSync2(abs).isFile())
6011
+ const abs = join16(root, key.slice(1));
6012
+ if (!existsSync14(abs) || !statSync3(abs).isFile())
3833
6013
  return sendText(res, 404, "text/plain", "Not found");
3834
6014
  res.writeHead(200, { "content-type": contentType(key), "cache-control": "no-store" });
3835
6015
  createReadStream(abs).pipe(res);
@@ -3872,11 +6052,11 @@ var startDevServer = async (options) => {
3872
6052
  if (conn)
3873
6053
  clients.add(conn);
3874
6054
  });
3875
- await new Promise((resolve4, reject) => {
6055
+ await new Promise((resolve5, reject) => {
3876
6056
  server.once("error", reject);
3877
6057
  server.listen(options.port, options.bind ?? "0.0.0.0", () => {
3878
6058
  server.removeListener("error", reject);
3879
- resolve4();
6059
+ resolve5();
3880
6060
  });
3881
6061
  });
3882
6062
  const address = server.address();
@@ -3912,24 +6092,24 @@ var pickLanAddress = () => {
3912
6092
  return null;
3913
6093
  };
3914
6094
  var loadDevToken = (root) => {
3915
- const path = join15(root, LECODES_DIR, "dev.json");
6095
+ const path = join17(root, LECODES_DIR, "dev.json");
3916
6096
  try {
3917
- const token2 = JSON.parse(readFileSync13(path, "utf8")).token;
6097
+ const token2 = JSON.parse(readFileSync15(path, "utf8")).token;
3918
6098
  if (token2 && /^[a-z0-9]{8,}$/.test(token2))
3919
6099
  return token2;
3920
6100
  } catch {}
3921
6101
  const token = randomBytes2(6).toString("hex");
3922
- mkdirSync9(join15(root, LECODES_DIR), { recursive: true });
3923
- writeFileSync10(path, JSON.stringify({ token }, null, 2) + `
6102
+ mkdirSync11(join17(root, LECODES_DIR), { recursive: true });
6103
+ writeFileSync12(path, JSON.stringify({ token }, null, 2) + `
3924
6104
  `);
3925
6105
  return token;
3926
6106
  };
3927
6107
  var printQr = async (url) => {
3928
6108
  try {
3929
6109
  const qrcode = (await Promise.resolve().then(() => __toESM(require_main(), 1))).default;
3930
- await new Promise((resolve4) => qrcode.generate(url, { small: true }, (out) => {
6110
+ await new Promise((resolve5) => qrcode.generate(url, { small: true }, (out) => {
3931
6111
  log(out);
3932
- resolve4();
6112
+ resolve5();
3933
6113
  }));
3934
6114
  } catch {
3935
6115
  note("(couldn't render a QR code — open the URL below on the device)");
@@ -3945,7 +6125,7 @@ var dev = async (args) => {
3945
6125
  root = process.cwd();
3946
6126
  manifest = null;
3947
6127
  }
3948
- const name = manifest?.name ?? basename6(root);
6128
+ const name = manifest?.name ?? basename7(root);
3949
6129
  if (typeof globalThis.Bun !== "undefined") {
3950
6130
  warnErr("Running under Bun: the websocket reload channel doesn't work here — run `lecodes dev` with Node (or use ?t=poll on the device URL).");
3951
6131
  }
@@ -4032,15 +6212,15 @@ ${result.error}`);
4032
6212
  await printQr(server.url);
4033
6213
  log(`Scan the QR with the LeCodes app, or open: ${c.bold(server.url)}`);
4034
6214
  note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`);
4035
- if (existsSync13(join15(root, LECODES_DIR, "manifest.json")))
6215
+ if (existsSync15(join17(root, LECODES_DIR, "manifest.json")))
4036
6216
  note(`Token stored in ${LECODES_DIR}/dev.json (delete it to rotate the URL).`);
4037
6217
  log("");
4038
6218
  log(`Watching ${root} — save a file to hot-reload. Ctrl+C to stop.`);
4039
6219
  };
4040
6220
 
4041
6221
  // src/serverDiff.ts
4042
- import { readFileSync as readFileSync14 } from "node:fs";
4043
- import { basename as basename7 } from "node:path";
6222
+ import { readFileSync as readFileSync16 } from "node:fs";
6223
+ import { basename as basename8 } from "node:path";
4044
6224
  var isTextAsset = (t) => t === "text" || t === "shader" || t === "scene";
4045
6225
  var computeServerDiff = (project, scan) => {
4046
6226
  const pathById = buildPaths(project.assets);
@@ -4050,15 +6230,15 @@ var computeServerDiff = (project, scan) => {
4050
6230
  continue;
4051
6231
  serverByPath.set(pathById.get(asset.id), asset);
4052
6232
  }
4053
- const localByPath = new Map(scan.files.map((f) => [f.path, f]));
6233
+ const localByPath = new Map(scan.files.map((f2) => [f2.path, f2]));
4054
6234
  const entries = [];
4055
6235
  const paths = new Set([...serverByPath.keys(), ...localByPath.keys()]);
4056
6236
  for (const path of [...paths].sort()) {
4057
6237
  const server = serverByPath.get(path);
4058
6238
  const local = localByPath.get(path);
4059
6239
  if (local && !server) {
4060
- const text = isTextKind(classifyKind(basename7(path)));
4061
- entries.push({ path, status: "added", binary: !text, serverText: "", localText: text ? readFileSync14(local.absPath, "utf8") : undefined });
6240
+ const text = isTextKind(classifyKind(basename8(path)));
6241
+ entries.push({ path, status: "added", binary: !text, serverText: "", localText: text ? readFileSync16(local.absPath, "utf8") : undefined });
4062
6242
  continue;
4063
6243
  }
4064
6244
  if (server && !local) {
@@ -4069,12 +6249,12 @@ var computeServerDiff = (project, scan) => {
4069
6249
  if (server && local) {
4070
6250
  if (isTextAsset(server.type)) {
4071
6251
  const serverText = server.text ?? "";
4072
- const localText = readFileSync14(local.absPath, "utf8");
6252
+ const localText = readFileSync16(local.absPath, "utf8");
4073
6253
  if (serverText !== localText)
4074
6254
  entries.push({ path, status: "modified", binary: false, serverText, localText });
4075
6255
  } else {
4076
6256
  const serverSize = server.file?.size;
4077
- const localSize = Buffer.byteLength(readFileSync14(local.absPath));
6257
+ const localSize = Buffer.byteLength(readFileSync16(local.absPath));
4078
6258
  if (serverSize !== undefined && serverSize !== localSize) {
4079
6259
  entries.push({ path, status: "modified", binary: true });
4080
6260
  }
@@ -4227,8 +6407,8 @@ var diff = async (args) => {
4227
6407
  var statusLabel = (e) => e.status === "added" ? c.green(" added ") : e.status === "removed" ? c.red(" removed ") : c.yellow(" modified ");
4228
6408
 
4229
6409
  // src/commands/init.ts
4230
- import { existsSync as existsSync14, mkdirSync as mkdirSync10, writeFileSync as writeFileSync11 } from "node:fs";
4231
- import { basename as basename8, dirname as dirname8, join as join16, resolve as resolve4 } from "node:path";
6410
+ import { existsSync as existsSync16, mkdirSync as mkdirSync12, writeFileSync as writeFileSync13 } from "node:fs";
6411
+ import { basename as basename9, dirname as dirname10, join as join18, resolve as resolve5 } from "node:path";
4232
6412
  var STARTER2 = `// Welcome to LeCodes — the whole SDK is available as globals, no imports needed.
4233
6413
  // Run it live on a device with \`lecodes dev\`, or bundle it with \`lecodes compile -o app.js\`.
4234
6414
 
@@ -4264,24 +6444,55 @@ const screen = UIScreen(
4264
6444
 
4265
6445
  screen.open()
4266
6446
  `;
6447
+ var readmeTemplate = (name) => `# ${name}
6448
+
6449
+ A LeCodes app. The whole SDK is available as globals (\`UIScreen\`, \`UIText\`, \`Scene2D\`, …) —
6450
+ no imports needed. \`main.ts\` is the entry point.
6451
+
6452
+ ## Run it
6453
+
6454
+ \`\`\`
6455
+ lecodes dev # live on your phone: scan the QR with the LeCodes app, hot reload
6456
+ lecodes compile -o app.js # bundle to a single self-contained JS file
6457
+ \`\`\`
6458
+
6459
+ ## Go native
6460
+
6461
+ \`\`\`
6462
+ lecodes app init ios # generate a native Xcode shell in ios/
6463
+ lecodes app sync # embed the compiled bundle + plugins into it
6464
+ lecodes app open # open it in Xcode, build & run
6465
+ \`\`\`
6466
+
6467
+ ## Layout
6468
+
6469
+ - \`main.ts\` — the app. Add more top-level .ts files freely; the largest export-free one
6470
+ is picked as the entry point.
6471
+ - \`assets/\` — images/fonts the app references (created on demand)
6472
+ - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit)
6473
+ - \`.lecodesignore\` — files a future \`lecodes push\` would skip
6474
+
6475
+ Full command list: \`lecodes --help\`.
6476
+ `;
4267
6477
  var init2 = async (args) => {
4268
6478
  const dirArg = (args._[0] ?? ".").replace(/[/\\]+$/, "") || ".";
4269
- const root = resolve4(dirArg);
4270
- const name = basename8(root);
4271
- mkdirSync10(root, { recursive: true });
6479
+ const root = resolve5(dirArg);
6480
+ const name = basename9(root);
6481
+ mkdirSync12(root, { recursive: true });
4272
6482
  let created = 0;
4273
6483
  const write = (rel, content) => {
4274
- const abs = join16(root, rel);
4275
- if (existsSync14(abs)) {
6484
+ const abs = join18(root, rel);
6485
+ if (existsSync16(abs)) {
4276
6486
  note(`kept ${rel}`);
4277
6487
  return;
4278
6488
  }
4279
- mkdirSync10(dirname8(abs), { recursive: true });
4280
- writeFileSync11(abs, content);
6489
+ mkdirSync12(dirname10(abs), { recursive: true });
6490
+ writeFileSync13(abs, content);
4281
6491
  log(`${c.green("created")} ${rel}`);
4282
6492
  created++;
4283
6493
  };
4284
6494
  write("main.ts", STARTER2);
6495
+ write("README.md", readmeTemplate(name));
4285
6496
  write(".lecodesignore", DEFAULT_IGNORE_FILE);
4286
6497
  const wroteTypes = materializeTypesLocal(root);
4287
6498
  writeTsconfig(root);
@@ -4308,20 +6519,20 @@ var login = async (args) => {
4308
6519
  };
4309
6520
 
4310
6521
  // src/commands/render.ts
4311
- import { writeFileSync as writeFileSync12 } from "node:fs";
4312
- import { resolve as resolve5 } from "node:path";
6522
+ import { writeFileSync as writeFileSync14 } from "node:fs";
6523
+ import { resolve as resolve6 } from "node:path";
4313
6524
  var LOCAL_ASSET_SCHEME2 = "lecodesfile://";
4314
6525
  var DEVICE_SAFE_AREA = {
4315
- none: [0, 0, 0, 0],
4316
- iphone: [47, 0, 34, 0],
4317
- android: [24, 0, 24, 0]
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"] }
4318
6529
  };
4319
6530
  var resolveSafeArea = (args) => {
4320
6531
  const explicit = flagStr(args, "safe-area");
4321
6532
  if (explicit) {
4322
6533
  const n = explicit.split(",").map((s) => Number(s.trim()));
4323
6534
  if (n.length === 4 && n.every((v) => Number.isFinite(v)))
4324
- return n;
6535
+ return { safeArea: n, safeAreaBars: [] };
4325
6536
  throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
4326
6537
  }
4327
6538
  const device = flagStr(args, "device");
@@ -4329,7 +6540,7 @@ var resolveSafeArea = (args) => {
4329
6540
  const preset = DEVICE_SAFE_AREA[device];
4330
6541
  if (!preset)
4331
6542
  throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
4332
- return preset;
6543
+ return { safeArea: preset.insets, safeAreaBars: preset.bars };
4333
6544
  }
4334
6545
  return;
4335
6546
  };
@@ -4352,6 +6563,13 @@ var render = async (args) => {
4352
6563
  const useLocalAssets = !publicUrlFlag && !flagBool(args, "remote-assets");
4353
6564
  const compileEntries = useLocalAssets ? entries.map((e) => e.type === "resource" && e.absPath ? { ...e, fileSrc: encodeURIComponent(e.absPath) } : e) : entries;
4354
6565
  const warnings = useLocalAssets ? collectWarnings.filter((w) => !w.includes("No server URL")) : collectWarnings;
6566
+ await applyProjectFonts(compileEntries, useLocalAssets ? {
6567
+ mode: "vendor",
6568
+ root,
6569
+ vendorDir: root,
6570
+ vendorPrefix: "",
6571
+ makeResourceEntry: (path, absPath) => ({ path, type: "resource", fileSrc: encodeURIComponent(absPath), absPath })
6572
+ } : { mode: "cdn", root });
4355
6573
  note("Compiling…");
4356
6574
  const js = await compileProject2({
4357
6575
  entries: compileEntries,
@@ -4384,7 +6602,7 @@ var render = async (args) => {
4384
6602
  localAssets: useLocalAssets,
4385
6603
  timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
4386
6604
  afterAnimations: !flagBool(args, "no-after-animations"),
4387
- safeArea: resolveSafeArea(args),
6605
+ ...resolveSafeArea(args),
4388
6606
  onConsole
4389
6607
  };
4390
6608
  const done = (flush) => flush(() => {
@@ -4405,7 +6623,7 @@ var render = async (args) => {
4405
6623
  if (isNullSink(pngFlag)) {
4406
6624
  note(`Discarded PNG (${pngFlag})`);
4407
6625
  } else {
4408
- writeFileSync12(resolve5(pngFlag), png);
6626
+ writeFileSync14(resolve6(pngFlag), png);
4409
6627
  note(`Wrote ${pngFlag} (${png.length} bytes, ${width}x${height})`);
4410
6628
  }
4411
6629
  return done((cb) => cb());
@@ -4420,7 +6638,7 @@ var render = async (args) => {
4420
6638
  return done((cb) => cb());
4421
6639
  }
4422
6640
  if (out) {
4423
- writeFileSync12(resolve5(out), result.text);
6641
+ writeFileSync14(resolve6(out), result.text);
4424
6642
  note(`Wrote ${out} (${result.text.length} bytes)`);
4425
6643
  return done((cb) => cb());
4426
6644
  }
@@ -4431,8 +6649,8 @@ var render = async (args) => {
4431
6649
  };
4432
6650
 
4433
6651
  // src/commands/pull.ts
4434
- import { existsSync as existsSync15, mkdirSync as mkdirSync11, rmSync, writeFileSync as writeFileSync13 } from "node:fs";
4435
- import { dirname as dirname9, join as join17 } from "node:path";
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";
4436
6654
  var pull = async (args) => {
4437
6655
  const root = findProjectRoot(process.cwd());
4438
6656
  const manifest = readManifest(root);
@@ -4452,9 +6670,9 @@ var pull = async (args) => {
4452
6670
  const remotePaths = new Set([...buildPaths(project.assets).entries()].filter(([id]) => project.assets.find((a) => a.id === id)?.type !== "folder").map(([, path]) => path));
4453
6671
  for (const path of Object.keys(manifest.files)) {
4454
6672
  if (!remotePaths.has(path)) {
4455
- const abs = join17(root, ...path.replace(/^\//, "").split("/"));
4456
- if (existsSync15(abs))
4457
- rmSync(abs);
6673
+ const abs = join19(root, ...path.replace(/^\//, "").split("/"));
6674
+ if (existsSync17(abs))
6675
+ rmSync2(abs);
4458
6676
  }
4459
6677
  }
4460
6678
  const files = await materializeAssets(root, project, apiUrl, token, progress);
@@ -4491,18 +6709,18 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
4491
6709
  const files = { ...manifest.files };
4492
6710
  for (const asset of targets) {
4493
6711
  const key = pathById.get(asset.id);
4494
- const abs = join17(root, ...key.replace(/^\//, "").split("/"));
4495
- mkdirSync11(dirname9(abs), { recursive: true });
6712
+ const abs = join19(root, ...key.replace(/^\//, "").split("/"));
6713
+ mkdirSync13(dirname11(abs), { recursive: true });
4496
6714
  let sha;
4497
6715
  if (asset.type === "file") {
4498
6716
  if (!asset.file?.src)
4499
6717
  continue;
4500
6718
  const bytes = await downloadBlob(apiUrl, asset.file.src);
4501
- writeFileSync13(abs, bytes);
6719
+ writeFileSync15(abs, bytes);
4502
6720
  sha = sha256(bytes);
4503
6721
  } else {
4504
6722
  const text = asset.text ?? "";
4505
- writeFileSync13(abs, text);
6723
+ writeFileSync15(abs, text);
4506
6724
  sha = sha256(text);
4507
6725
  }
4508
6726
  files[key] = { id: asset.id, type: kindOf2(asset.type), sha };
@@ -4514,8 +6732,8 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
4514
6732
  };
4515
6733
 
4516
6734
  // src/commands/push.ts
4517
- import { basename as basename9 } from "node:path";
4518
- import { readFileSync as readFileSync15 } from "node:fs";
6735
+ import { basename as basename10 } from "node:path";
6736
+ import { readFileSync as readFileSync17 } from "node:fs";
4519
6737
  var push = async (args) => {
4520
6738
  const root = findProjectRoot(process.cwd());
4521
6739
  const manifest = readManifest(root);
@@ -4569,14 +6787,14 @@ var push = async (args) => {
4569
6787
  let uploaded = 0;
4570
6788
  for (const file of scan.files) {
4571
6789
  const source = sourceFor(file.path);
4572
- const kind = source?.type ?? classifyKind(basename9(file.path));
6790
+ const kind = source?.type ?? classifyKind(basename10(file.path));
4573
6791
  const entry = { path: file.path, type: kind };
4574
6792
  if (source)
4575
6793
  entry.assetId = source.id;
4576
6794
  if (isTextKind(kind)) {
4577
- entry.text = readFileSync15(file.absPath, "utf8");
6795
+ entry.text = readFileSync17(file.absPath, "utf8");
4578
6796
  } else if (!source || source.sha !== file.sha) {
4579
- const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename9(file.path), readFileSync15(file.absPath));
6797
+ const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync17(file.absPath));
4580
6798
  entry.blobFileId = blob.id;
4581
6799
  uploaded++;
4582
6800
  }
@@ -4592,7 +6810,7 @@ var push = async (args) => {
4592
6810
  reportPublish(res);
4593
6811
  return;
4594
6812
  }
4595
- const shaByPath = new Map(scan.files.map((f) => [f.path, f.sha]));
6813
+ const shaByPath = new Map(scan.files.map((f2) => [f2.path, f2.sha]));
4596
6814
  const newFiles = {};
4597
6815
  for (const node of res.nodes) {
4598
6816
  newFiles[node.path] = { id: node.id, type: node.type, sha: shaByPath.get(node.path) ?? "" };
@@ -4629,22 +6847,22 @@ var reportPublish = (res) => {
4629
6847
  };
4630
6848
 
4631
6849
  // src/commands/scene.ts
4632
- import { existsSync as existsSync17, readFileSync as readFileSync17, readdirSync as readdirSync7, statSync as statSync3, writeFileSync as writeFileSync14 } from "node:fs";
4633
- import { basename as basename10, dirname as dirname10, join as join19, resolve as resolve6 } from "node:path";
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";
4634
6852
 
4635
6853
  // src/compile/sceneCompile.ts
4636
- import { existsSync as existsSync16, readFileSync as readFileSync16 } from "node:fs";
4637
- import { join as join18 } from "node:path";
6854
+ import { existsSync as existsSync18, readFileSync as readFileSync18 } from "node:fs";
6855
+ import { join as join20 } from "node:path";
4638
6856
  var TEXT_EXT = /\.(ts|js|json|svg|txt|md)$/i;
4639
6857
  var rel = (key) => key.replace(/^\//, "");
4640
6858
  var resolveHarness = (loadSceneHarness2) => {
4641
- const inRepo = join18(distRoot, "..", "projects", "src", "scene-editor");
6859
+ const inRepo = join20(distRoot, "..", "projects", "src", "scene-editor");
4642
6860
  try {
4643
- return loadSceneHarness2(existsSync16(inRepo) ? inRepo : undefined);
6861
+ return loadSceneHarness2(existsSync18(inRepo) ? inRepo : undefined);
4644
6862
  } catch {
4645
- const vendored = join18(distRoot, "runtime", "scene-harness.json");
4646
- if (existsSync16(vendored))
4647
- return JSON.parse(readFileSync16(vendored, "utf8"));
6863
+ const vendored = join20(distRoot, "runtime", "scene-harness.json");
6864
+ if (existsSync18(vendored))
6865
+ return JSON.parse(readFileSync18(vendored, "utf8"));
4648
6866
  throw new CliError("scene-editor harness sources not found — reinstall the CLI or set LECODES_SCENE_HARNESS_DIR.");
4649
6867
  }
4650
6868
  };
@@ -4656,7 +6874,7 @@ var compileSceneEditor = async (opts) => {
4656
6874
  for (const file of scan.files) {
4657
6875
  const path = "./" + rel(file.path);
4658
6876
  if (TEXT_EXT.test(path)) {
4659
- entries.push({ path, type: "text", text: readFileSync16(file.absPath, "utf8") });
6877
+ entries.push({ path, type: "text", text: readFileSync18(file.absPath, "utf8") });
4660
6878
  } else {
4661
6879
  entries.push({ path, type: "resource", fileSrc: "/project/" + rel(file.path) });
4662
6880
  }
@@ -4696,24 +6914,24 @@ var getContext2 = (args) => {
4696
6914
  manifest = null;
4697
6915
  const pathArg = args._[0];
4698
6916
  if (pathArg) {
4699
- const abs = resolve6(process.cwd(), pathArg);
4700
- if (existsSync17(abs) && statSync3(abs).isDirectory())
6917
+ const abs = resolve7(process.cwd(), pathArg);
6918
+ if (existsSync19(abs) && statSync4(abs).isDirectory())
4701
6919
  root = abs;
4702
- else if (abs.endsWith(".scene.ts") && existsSync17(abs))
4703
- root = dirname10(abs);
6920
+ else if (abs.endsWith(".scene.ts") && existsSync19(abs))
6921
+ root = dirname12(abs);
4704
6922
  else
4705
6923
  throw new CliError(`No such folder or .scene.ts: ${pathArg}`);
4706
6924
  }
4707
6925
  }
4708
- return { root, manifest, name: manifest?.name ?? basename10(root) };
6926
+ return { root, manifest, name: manifest?.name ?? basename11(root) };
4709
6927
  };
4710
6928
  var writeMcpConfig2 = (root, port) => {
4711
- const path = join19(root, ".mcp.json");
6929
+ const path = join21(root, ".mcp.json");
4712
6930
  const entry = { type: "http", url: `http://localhost:${port}/mcp` };
4713
6931
  let config = {};
4714
- if (existsSync17(path)) {
6932
+ if (existsSync19(path)) {
4715
6933
  try {
4716
- config = JSON.parse(readFileSync17(path, "utf8"));
6934
+ config = JSON.parse(readFileSync19(path, "utf8"));
4717
6935
  } catch {
4718
6936
  warnErr(".mcp.json exists but isn't valid JSON — left it untouched. Add the lecodes-scene server by hand.");
4719
6937
  return;
@@ -4726,17 +6944,17 @@ var writeMcpConfig2 = (root, port) => {
4726
6944
  return;
4727
6945
  }
4728
6946
  config.mcpServers["lecodes-scene"] = entry;
4729
- writeFileSync14(path, JSON.stringify(config, null, 2) + `
6947
+ writeFileSync16(path, JSON.stringify(config, null, 2) + `
4730
6948
  `);
4731
6949
  note(`${existing ? "updated" : "created"} .mcp.json (lecodes-scene MCP → ${entry.url})`);
4732
6950
  };
4733
6951
  var countScenes = (dir) => {
4734
6952
  let n = 0;
4735
- for (const name of readdirSync7(dir)) {
6953
+ for (const name of readdirSync8(dir)) {
4736
6954
  if (name === "node_modules" || name.startsWith("."))
4737
6955
  continue;
4738
- const full = join19(dir, name);
4739
- if (statSync3(full).isDirectory())
6956
+ const full = join21(dir, name);
6957
+ if (statSync4(full).isDirectory())
4740
6958
  n += countScenes(full);
4741
6959
  else if (name.endsWith(".scene.ts"))
4742
6960
  n++;
@@ -4788,12 +7006,12 @@ var status = async (_args) => {
4788
7006
  info(c.dim("Working tree clean — nothing to push.") + ignoredNote);
4789
7007
  return;
4790
7008
  }
4791
- for (const f of diff2.added) {
4792
- if (!diff2.moves.has(f.path))
4793
- log(c.green(` added ${f.path}`));
7009
+ for (const f2 of diff2.added) {
7010
+ if (!diff2.moves.has(f2.path))
7011
+ log(c.green(` added ${f2.path}`));
4794
7012
  }
4795
- for (const f of diff2.modified)
4796
- log(c.yellow(` modified ${f.path}`));
7013
+ for (const f2 of diff2.modified)
7014
+ log(c.yellow(` modified ${f2.path}`));
4797
7015
  for (const [to, from] of diff2.moves)
4798
7016
  log(c.cyan(` moved ${from} → ${to}`));
4799
7017
  const movedFrom = new Set(diff2.moves.values());
@@ -4806,21 +7024,22 @@ var status = async (_args) => {
4806
7024
  };
4807
7025
 
4808
7026
  // src/index.ts
4809
- import { existsSync as existsSync19, realpathSync as realpathSync2 } from "node:fs";
4810
- import { delimiter as delimiter2, dirname as dirname12, join as join21 } from "node:path";
7027
+ import { existsSync as existsSync21, readFileSync as readFileSync21, realpathSync as realpathSync2 } from "node:fs";
7028
+ import { createRequire as createRequire4 } from "node:module";
7029
+ import { delimiter as delimiter2, dirname as dirname14, join as join23 } from "node:path";
4811
7030
 
4812
7031
  // src/commands/install.ts
4813
- import { execFileSync } from "node:child_process";
4814
- import { appendFileSync as appendFileSync2, chmodSync, cpSync, existsSync as existsSync18, mkdirSync as mkdirSync12, readFileSync as readFileSync18, realpathSync, rmSync as rmSync2, symlinkSync } from "node:fs";
4815
- import { homedir as homedir2 } from "node:os";
4816
- import { basename as basename11, delimiter, dirname as dirname11, join as join20, resolve as resolve7, sep } from "node:path";
7032
+ 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";
7034
+ 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";
4817
7036
  var isWin = process.platform === "win32";
4818
- var HOME = homedir2();
7037
+ var HOME = homedir3();
4819
7038
  var safeRealpath = (p) => {
4820
7039
  try {
4821
7040
  return realpathSync(p);
4822
7041
  } catch {
4823
- return resolve7(p);
7042
+ return resolve8(p);
4824
7043
  }
4825
7044
  };
4826
7045
  var onPath = (dir) => {
@@ -4836,25 +7055,25 @@ var onPath = (dir) => {
4836
7055
  });
4837
7056
  };
4838
7057
  var install = async (args) => {
4839
- const bundle = dirname11(process.execPath);
4840
- const exeName = basename11(process.execPath);
7058
+ const bundle = dirname13(process.execPath);
7059
+ const exeName = basename12(process.execPath);
4841
7060
  const chiselName = isWin ? "chisel.exe" : "chisel";
4842
- if (!existsSync18(join20(bundle, "runtime")) || !existsSync18(join20(bundle, chiselName))) {
7061
+ if (!existsSync20(join22(bundle, "runtime")) || !existsSync20(join22(bundle, chiselName))) {
4843
7062
  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.");
4844
7063
  }
4845
- const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f) => existsSync18(join20(bundle, f)));
7064
+ const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync20(join22(bundle, f2)));
4846
7065
  if (isWin)
4847
7066
  return installWindows(args, bundle, companions);
4848
7067
  return installUnix(args, bundle, companions);
4849
7068
  };
4850
7069
  var copyBundle = (bundle, target, companions) => {
4851
- mkdirSync12(target, { recursive: true });
7070
+ mkdirSync14(target, { recursive: true });
4852
7071
  if (safeRealpath(target) === safeRealpath(bundle))
4853
7072
  return;
4854
- for (const f of companions) {
4855
- const dest = join20(target, f);
4856
- rmSync2(dest, { recursive: true, force: true });
4857
- cpSync(join20(bundle, f), dest, { recursive: true });
7073
+ for (const f2 of companions) {
7074
+ const dest = join22(target, f2);
7075
+ rmSync3(dest, { recursive: true, force: true });
7076
+ cpSync2(join22(bundle, f2), dest, { recursive: true });
4858
7077
  }
4859
7078
  };
4860
7079
  var permError = (e, dir, system) => {
@@ -4866,41 +7085,41 @@ var permError = (e, dir, system) => {
4866
7085
  };
4867
7086
  var installUnix = (args, bundle, companions) => {
4868
7087
  const system = flagBool(args, "system");
4869
- const libDir = resolve7(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join20(HOME, ".local", "lib", "lecodes-cli")));
4870
- const binDir = resolve7(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join20(HOME, ".local", "bin")));
4871
- const link = join20(binDir, "lecodes");
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")));
7090
+ const link = join22(binDir, "lecodes");
4872
7091
  try {
4873
7092
  copyBundle(bundle, libDir, companions);
4874
- chmodSync(join20(libDir, "lecodes"), 493);
4875
- chmodSync(join20(libDir, "chisel"), 493);
7093
+ chmodSync(join22(libDir, "lecodes"), 493);
7094
+ chmodSync(join22(libDir, "chisel"), 493);
4876
7095
  } catch (e) {
4877
7096
  throw permError(e, libDir, system);
4878
7097
  }
4879
7098
  try {
4880
- mkdirSync12(binDir, { recursive: true });
4881
- rmSync2(link, { force: true });
4882
- symlinkSync(join20(libDir, "lecodes"), link);
7099
+ mkdirSync14(binDir, { recursive: true });
7100
+ rmSync3(link, { force: true });
7101
+ symlinkSync(join22(libDir, "lecodes"), link);
4883
7102
  } catch (e) {
4884
7103
  throw permError(e, binDir, system);
4885
7104
  }
4886
7105
  success(`Installed to ${c.bold(libDir)}`);
4887
- log(` ${c.dim(link + " → " + join20(libDir, "lecodes"))}`);
7106
+ log(` ${c.dim(link + " → " + join22(libDir, "lecodes"))}`);
4888
7107
  finishPathUnix(args, binDir);
4889
7108
  };
4890
7109
  var shellRc = () => {
4891
- const shell = basename11(process.env.SHELL ?? "");
7110
+ const shell = basename12(process.env.SHELL ?? "");
4892
7111
  if (shell === "zsh")
4893
- return { file: join20(HOME, ".zshrc"), name: "~/.zshrc" };
7112
+ return { file: join22(HOME, ".zshrc"), name: "~/.zshrc" };
4894
7113
  if (shell === "bash")
4895
- return { file: join20(HOME, ".bashrc"), name: "~/.bashrc" };
4896
- return { file: join20(HOME, ".profile"), name: "~/.profile" };
7114
+ return { file: join22(HOME, ".bashrc"), name: "~/.bashrc" };
7115
+ return { file: join22(HOME, ".profile"), name: "~/.profile" };
4897
7116
  };
4898
7117
  var PATH_MARKER = "# Added by `lecodes install`";
4899
7118
  var appendPathToShellRc = (binDir) => {
4900
7119
  const { file, name } = shellRc();
4901
7120
  const line = `export PATH="${binDir}:$PATH"`;
4902
7121
  try {
4903
- const existing = existsSync18(file) ? readFileSync18(file, "utf8") : "";
7122
+ const existing = existsSync20(file) ? readFileSync20(file, "utf8") : "";
4904
7123
  if (existing.includes(line))
4905
7124
  return name;
4906
7125
  appendFileSync2(file, `${existing && !existing.endsWith(`
@@ -4938,7 +7157,7 @@ var finishPathUnix = (args, binDir) => {
4938
7157
  log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`);
4939
7158
  };
4940
7159
  var installWindows = (args, bundle, companions) => {
4941
- const target = resolve7(flagStr(args, "dir") ?? join20(process.env.LOCALAPPDATA ?? join20(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
7160
+ const target = resolve8(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
4942
7161
  try {
4943
7162
  copyBundle(bundle, target, companions);
4944
7163
  } catch (e) {
@@ -4966,7 +7185,7 @@ var installWindows = (args, bundle, companions) => {
4966
7185
  var addToUserPathWindows = (dir) => {
4967
7186
  const script = "$d = $env:LECODES_INSTALL_DIR;" + "$p = [Environment]::GetEnvironmentVariable('Path','User'); if (-not $p) { $p = '' };" + "$parts = @($p.Split(';') | Where-Object { $_ -ne '' });" + "if ($parts -notcontains $d) { [Environment]::SetEnvironmentVariable('Path', (@($parts + $d) -join ';'), 'User') }";
4968
7187
  try {
4969
- execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { stdio: "ignore", env: { ...process.env, LECODES_INSTALL_DIR: dir } });
7188
+ execFileSync2("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { stdio: "ignore", env: { ...process.env, LECODES_INSTALL_DIR: dir } });
4970
7189
  return true;
4971
7190
  } catch {
4972
7191
  return false;
@@ -4975,9 +7194,9 @@ var addToUserPathWindows = (dir) => {
4975
7194
 
4976
7195
  // src/index.ts
4977
7196
  var STANDALONE = (() => {
4978
- const dir = dirname12(process.execPath);
7197
+ const dir = dirname14(process.execPath);
4979
7198
  const chisel = process.platform === "win32" ? "chisel.exe" : "chisel";
4980
- return existsSync19(join21(dir, "runtime")) && existsSync19(join21(dir, chisel));
7199
+ return existsSync21(join23(dir, "runtime")) && existsSync21(join23(dir, chisel));
4981
7200
  })();
4982
7201
  var INSTALLED = STANDALONE && (() => {
4983
7202
  const exe = process.platform === "win32" ? "lecodes.exe" : "lecodes";
@@ -4992,8 +7211,8 @@ var INSTALLED = STANDALONE && (() => {
4992
7211
  if (!d)
4993
7212
  return false;
4994
7213
  try {
4995
- const cand = join21(d, exe);
4996
- return existsSync19(cand) && realpathSync2(cand) === self;
7214
+ const cand = join23(d, exe);
7215
+ return existsSync21(cand) && realpathSync2(cand) === self;
4997
7216
  } catch {
4998
7217
  return false;
4999
7218
  }
@@ -5080,12 +7299,73 @@ ${c.bold("Commands:")}
5080
7299
  --port <n> editor-server port (default 4488)
5081
7300
  --no-open don't open the browser
5082
7301
  --no-mcp don't register the MCP server in .mcp.json
7302
+ app init ios Generate a native Xcode shell in <project>/ios/ (offline-first:
7303
+ runs the bundled app.js; ios/ is never pushed to LeCodes)
7304
+ --name <s> display name (default: app.json / folder name)
7305
+ --bundle-id <id> iOS bundle identifier
7306
+ --team <id> Apple development team id
7307
+ --variant <v> SDK variant: auto (default — sync picks the
7308
+ smallest of core/2d/3d/full covering what
7309
+ the bundle uses), or one of those to pin
7310
+ app sync Make the shell match the project: compile the bundle + embed
7311
+ its assets into ios/App/Resources/ (offline by default, also
7312
+ for server projects; app.json "resources": {"remote": [globs]}
7313
+ keeps heavy assets streaming from the server), pick the SDK
7314
+ variant from the bundle's engine use, vendor app.json
7315
+ plugins (pinned in plugins.lock.json), regenerate the
7316
+ CLI-owned shell files
7317
+ app plugin <list|add|remove> Manage native plugins in app.json (then run 'lecodes app sync')
7318
+ add takes a known id (camera, qr-scanner) or github:owner/repo[@tag]
7319
+ app open [ios] Open the native shell in its IDE (Xcode; Android Studio later)
7320
+ app doctor Preflight checks for building/uploading the shell
7321
+ app build ios Sync, archive and export the .ipa (automatic signing)
7322
+ --configuration <c> build configuration (default Release)
7323
+ --method <m> export method (default app-store-connect)
7324
+ --no-export stop after the archive
7325
+ --no-sync skip the lecodes app sync step
7326
+ app upload ios Upload the archive to App Store Connect (then bumps buildNumber)
7327
+ --key-path <p8> --key-id <id> --issuer-id <id>
7328
+ ASC API key; stored in ~/.lecodes/asc.json
5083
7329
 
5084
7330
  Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKEN).`;
5085
- var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, design, scene, dev };
7331
+ var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, design, scene, dev, app };
5086
7332
  if (STANDALONE)
5087
7333
  commands.install = install;
5088
7334
  var readVersion = () => package_default.version ?? "unknown";
7335
+ var OPTIONAL_PEERS = [
7336
+ ["lecodes-renderer", "lecodes-renderer/headless"],
7337
+ ["lecodes-design", "lecodes-design/server"],
7338
+ ["lecodes-3d-editor", "lecodes-3d-editor/server"]
7339
+ ];
7340
+ var peerVersion = (name, probe) => {
7341
+ try {
7342
+ const require2 = createRequire4(import.meta.url);
7343
+ let dir = dirname14(require2.resolve(probe));
7344
+ for (let depth = 0;depth < 16; depth++) {
7345
+ const manifest = join23(dir, "package.json");
7346
+ if (existsSync21(manifest)) {
7347
+ const parsed = JSON.parse(readFileSync21(manifest, "utf8"));
7348
+ if (parsed.name === name)
7349
+ return parsed.version ?? "unknown";
7350
+ }
7351
+ const parent = dirname14(dir);
7352
+ if (parent === dir)
7353
+ break;
7354
+ dir = parent;
7355
+ }
7356
+ } catch {}
7357
+ return null;
7358
+ };
7359
+ var printVersion = () => {
7360
+ const rows = [["lecodes-cli", readVersion()]];
7361
+ for (const [name, probe] of OPTIONAL_PEERS) {
7362
+ const version = peerVersion(name, probe);
7363
+ rows.push([name, version ?? c.dim("not installed")]);
7364
+ }
7365
+ const width = Math.max(...rows.map(([label]) => label.length));
7366
+ for (const [label, value] of rows)
7367
+ log(`${label.padEnd(width)} ${value}`);
7368
+ };
5089
7369
  var main = async () => {
5090
7370
  const argv = process.argv.slice(2);
5091
7371
  const command = argv[0];
@@ -5094,7 +7374,7 @@ var main = async () => {
5094
7374
  return;
5095
7375
  }
5096
7376
  if (command === "--version" || command === "-v" || command === "version") {
5097
- log(readVersion());
7377
+ printVersion();
5098
7378
  return;
5099
7379
  }
5100
7380
  const run = commands[command];