lecodes-cli 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,7 @@ lecodes preview > app.js # compile the local project to JS
35
35
  | `pull` | Overwrite local files with the project's current remote state. `--force` to discard local edits. |
36
36
  | `push` | Push all local changes as **one** new checkpoint. `-m`/`--message` for the message, `--force` to override the "server moved on" guard. |
37
37
  | `preview` | Compile the local project to a single JS bundle, printed to **stdout** (pipeable). `-o`/`--out <file>` to write a file, `--entry <path>` to override the entrypoint, `--no-header` to omit the metadata header, `--public-url <url>` for the asset URL prefix. Diagnostics go to stderr. |
38
- | `render` | Render the local **UI** project. By default emits a semantic JSON description (great for AI/automated checks) to stdout or `-o`/`--out <file>`; `--png [file]` rasterizes a PNG (default `screenshot.png`, `--scale <n>` for DPR). `--width`/`--height` set the viewport (default 390×844). Requires the optional `lecodes-render` package (`npm i -D lecodes-render`); PNG also needs `@napi-rs/canvas`. 3D scenes aren't rendered. |
38
+ | `render` | Render the local **UI** project. By default emits a semantic JSON description (great for AI/automated checks) to stdout or `-o`/`--out <file>`; `--png [file]` rasterizes a PNG (default `screenshot.png`, `--scale <n>` for DPR). `--width`/`--height` set the viewport (default 390×844). Requires the optional `lecodes-renderer` package (`npm i -D lecodes-renderer`); PNG also needs `@napi-rs/canvas`. 3D scenes aren't rendered. |
39
39
 
40
40
  ## How it works
41
41
 
package/dist/index.js CHANGED
@@ -2,8 +2,13 @@
2
2
  import { createRequire } from "node:module";
3
3
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
 
5
+ // src/index.ts
6
+ import { readFileSync as readFileSync8 } from "node:fs";
7
+ import { dirname as dirname5, join as join9 } from "node:path";
8
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
9
+
5
10
  // src/commands/clone.ts
6
- import { existsSync as existsSync2, mkdirSync as mkdirSync5, readdirSync } from "node:fs";
11
+ import { existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync } from "node:fs";
7
12
  import { resolve } from "node:path";
8
13
 
9
14
  // src/util.ts
@@ -23,6 +28,7 @@ var log = (msg = "") => process.stdout.write(msg + `
23
28
  `);
24
29
  var info = (msg) => log(msg);
25
30
  var success = (msg) => log(c.green("✓ ") + msg);
31
+ var warn = (msg) => log(c.yellow("! ") + msg);
26
32
  var logErr = (msg = "") => process.stderr.write(msg + `
27
33
  `);
28
34
  var note = (msg) => logErr(c.dim(msg));
@@ -44,6 +50,8 @@ var endProgress = () => {
44
50
 
45
51
  class CliError extends Error {
46
52
  }
53
+ var NULL_SINKS = new Set(["/dev/null", "nul", "//./nul"]);
54
+ var isNullSink = (path) => NULL_SINKS.has(path.trim().replace(/\\/g, "/").toLowerCase());
47
55
  var sha256 = (data) => createHash("sha256").update(data).digest("hex");
48
56
  var toKey = (path) => {
49
57
  const parts = path.replace(/\\/g, "/").split("/").map((p) => p.trim()).filter((p) => p && p !== ".");
@@ -59,6 +67,12 @@ var prompt = (question, fallback) => new Promise((resolve) => {
59
67
  resolve(answer.trim() || fallback || "");
60
68
  });
61
69
  });
70
+ var confirm = async (question, fallback = false) => {
71
+ const answer = (await prompt(`${question} ${fallback ? "[Y/n]" : "[y/N]"}`)).toLowerCase();
72
+ if (!answer)
73
+ return fallback;
74
+ return answer === "y" || answer === "yes";
75
+ };
62
76
  var parseArgs = (argv) => {
63
77
  const out = { _: [], flags: {} };
64
78
  for (let i = 0;i < argv.length; i++) {
@@ -154,6 +168,7 @@ var uploadBlob = (apiUrl, token, uuid, name, bytes) => {
154
168
  return apiRequest(apiUrl, `/projects/${uuid}/blobs`, { token, body: form });
155
169
  };
156
170
  var sync = (apiUrl, token, uuid, payload) => apiRequest(apiUrl, `/projects/${uuid}/sync`, { token, body: payload });
171
+ var publishProject = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}/publish`, { token, body: {} });
157
172
  var downloadBlob = async (apiUrl, src) => {
158
173
  const resp = await fetch(`${apiUrl}${src}`);
159
174
  if (!resp.ok)
@@ -328,6 +343,96 @@ var writeTsconfig = (root) => {
328
343
  writeFileSync4(join4(root, "tsconfig.json"), JSON.stringify(TSCONFIG, null, 2));
329
344
  };
330
345
 
346
+ // src/ignore.ts
347
+ import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync5 } from "node:fs";
348
+ import { join as join5 } from "node:path";
349
+ var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store"];
350
+ var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
351
+ # Only untracked files are affected — assets already in the project stay tracked
352
+ # even if they match a pattern. Built-in defaults (node_modules, _*, *.log) always apply.
353
+
354
+ # Render / preview outputs
355
+ screenshot.png
356
+ shots/
357
+ *.png
358
+
359
+ # Local notes & dumps (uncomment to ignore)
360
+ # *.json
361
+ # notes.md
362
+ `;
363
+ var translate = (glob) => {
364
+ let re = "";
365
+ for (let i = 0;i < glob.length; i++) {
366
+ const ch = glob[i];
367
+ if (ch === "*") {
368
+ if (glob[i + 1] === "*") {
369
+ re += ".*";
370
+ i++;
371
+ if (glob[i + 1] === "/")
372
+ i++;
373
+ } else
374
+ re += "[^/]*";
375
+ } else if (ch === "?")
376
+ re += "[^/]";
377
+ else if (ch === "/")
378
+ re += "/";
379
+ else if ("\\^$.|+()[]{}".includes(ch))
380
+ re += "\\" + ch;
381
+ else
382
+ re += ch;
383
+ }
384
+ return re;
385
+ };
386
+ var compile = (raw) => {
387
+ let p = raw.trim();
388
+ if (!p || p.startsWith("#"))
389
+ return null;
390
+ let negate = false;
391
+ if (p.startsWith("!")) {
392
+ negate = true;
393
+ p = p.slice(1);
394
+ }
395
+ let dirOnly = false;
396
+ if (p.endsWith("/")) {
397
+ dirOnly = true;
398
+ p = p.slice(0, -1);
399
+ }
400
+ const anchored = p.startsWith("/") || p.includes("/");
401
+ if (p.startsWith("/"))
402
+ p = p.slice(1);
403
+ if (!p)
404
+ return null;
405
+ const body = translate(p);
406
+ const re = anchored ? new RegExp("^/" + body + "(/.*)?$") : new RegExp("(^|/)" + body + "(/.*)?$");
407
+ return { re, negate, dirOnly };
408
+ };
409
+ var buildMatcher = (rules) => (key, isDir) => {
410
+ let ignored = false;
411
+ for (const r of rules) {
412
+ if (r.dirOnly && !isDir)
413
+ continue;
414
+ if (r.re.test(key))
415
+ ignored = !r.negate;
416
+ }
417
+ return ignored;
418
+ };
419
+ var loadIgnore = (root) => {
420
+ const lines = [...BUILTIN_PATTERNS];
421
+ const file = join5(root, ".lecodesignore");
422
+ if (existsSync2(file)) {
423
+ try {
424
+ lines.push(...readFileSync3(file, "utf8").split(/\r?\n/));
425
+ } catch {}
426
+ }
427
+ const rules = lines.map(compile).filter((r) => r !== null);
428
+ return buildMatcher(rules);
429
+ };
430
+ var writeDefaultIgnore = (root) => {
431
+ const file = join5(root, ".lecodesignore");
432
+ if (!existsSync2(file))
433
+ writeFileSync5(file, DEFAULT_IGNORE_FILE);
434
+ };
435
+
331
436
  // src/commands/clone.ts
332
437
  var parseUuid = (input) => {
333
438
  if (!input)
@@ -347,7 +452,7 @@ var clone = async (args) => {
347
452
  const project = await getProject(apiUrl, token, uuid);
348
453
  const dir = args._[1] ?? slug(project.name);
349
454
  const root = resolve(dir);
350
- if (existsSync2(root) && readdirSync(root).length > 0) {
455
+ if (existsSync3(root) && readdirSync(root).length > 0) {
351
456
  throw new CliError(`Directory "${dir}" already exists and is not empty.`);
352
457
  }
353
458
  mkdirSync5(root, { recursive: true });
@@ -359,12 +464,310 @@ var clone = async (args) => {
359
464
  await materializeTypes(root, apiUrl);
360
465
  writeTsconfig(root);
361
466
  }
467
+ writeDefaultIgnore(root);
362
468
  const commits = await getCommits(apiUrl, token, uuid);
363
469
  writeManifest(root, { uuid, apiUrl, name: project.name, baseHeadId: commits.headId, files });
364
470
  success(`Cloned ${c.bold(project.name)} into ${c.bold(dir)}/ (${Object.keys(files).length} files)`);
365
471
  info(c.dim(` cd ${dir} — edit, then \`lecodes push -m "your message"\``));
366
472
  };
367
473
 
474
+ // src/localFiles.ts
475
+ import { readdirSync as readdirSync2, readFileSync as readFileSync4 } from "node:fs";
476
+ import { join as join6, relative } from "node:path";
477
+ var SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"]);
478
+ var SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"]);
479
+ var TEXT_EXTS = new Set([
480
+ "ts",
481
+ "tsx",
482
+ "js",
483
+ "jsx",
484
+ "mjs",
485
+ "cjs",
486
+ "json",
487
+ "svg",
488
+ "glsl",
489
+ "vert",
490
+ "frag",
491
+ "txt",
492
+ "md",
493
+ "css",
494
+ "html",
495
+ "xml",
496
+ "yml",
497
+ "yaml",
498
+ "csv"
499
+ ]);
500
+ var ext = (name) => {
501
+ const i = name.lastIndexOf(".");
502
+ return i < 0 ? "" : name.slice(i + 1).toLowerCase();
503
+ };
504
+ var classifyKind = (name) => {
505
+ const e = ext(name);
506
+ if (e === "mat")
507
+ return "shader";
508
+ if (e === "scene")
509
+ return "scene";
510
+ return TEXT_EXTS.has(e) ? "text" : "resource";
511
+ };
512
+ var isTextKind = (kind) => kind !== "resource";
513
+ var scanLocal = (root, opts = {}) => {
514
+ const { ignore, tracked } = opts;
515
+ const files = [];
516
+ const dirs = [];
517
+ const ignored = [];
518
+ const isTracked = (key) => tracked?.has(key) ?? false;
519
+ const hasTrackedUnder = (key) => {
520
+ if (!tracked)
521
+ return false;
522
+ const prefix = key + "/";
523
+ for (const t of tracked)
524
+ if (t.startsWith(prefix))
525
+ return true;
526
+ return false;
527
+ };
528
+ const walk = (dir, parentIgnored) => {
529
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
530
+ const abs = join6(dir, entry.name);
531
+ const key = toKey(relative(root, abs));
532
+ if (entry.isDirectory()) {
533
+ if (SKIP_DIRS.has(entry.name))
534
+ continue;
535
+ const dirIgnored = parentIgnored || !!ignore && ignore(key, true);
536
+ if (dirIgnored && !isTracked(key) && !hasTrackedUnder(key)) {
537
+ ignored.push(key + "/");
538
+ continue;
539
+ }
540
+ dirs.push(key);
541
+ walk(abs, dirIgnored);
542
+ } else if (entry.isFile()) {
543
+ if (SKIP_FILES.has(entry.name))
544
+ continue;
545
+ const fileIgnored = parentIgnored || !!ignore && ignore(key, false);
546
+ if (fileIgnored && !isTracked(key)) {
547
+ ignored.push(key);
548
+ continue;
549
+ }
550
+ files.push({ path: key, absPath: abs, sha: sha256(readFileSync4(abs)) });
551
+ }
552
+ }
553
+ };
554
+ walk(root, false);
555
+ return { files, dirs, ignored };
556
+ };
557
+ var scanProject = (root, manifest) => scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest.files)) });
558
+ var diffLocal = (manifest, scan) => {
559
+ const added = [];
560
+ const modified = [];
561
+ const localByPath = new Map(scan.files.map((f) => [f.path, f]));
562
+ for (const file of scan.files) {
563
+ const known = manifest.files[file.path];
564
+ if (!known)
565
+ added.push(file);
566
+ else if (known.sha !== file.sha)
567
+ modified.push(file);
568
+ }
569
+ const deleted = Object.keys(manifest.files).filter((path) => !localByPath.has(path));
570
+ const moves = new Map;
571
+ const claimed = new Set;
572
+ for (const file of added) {
573
+ const from = deleted.find((path) => !claimed.has(path) && manifest.files[path].sha === file.sha);
574
+ if (from) {
575
+ moves.set(file.path, from);
576
+ claimed.add(from);
577
+ }
578
+ }
579
+ return { added, modified, deleted, moves };
580
+ };
581
+ var hasChanges = (diff) => diff.added.length + diff.modified.length + diff.deleted.length > 0;
582
+
583
+ // src/serverDiff.ts
584
+ import { readFileSync as readFileSync5 } from "node:fs";
585
+ import { basename } from "node:path";
586
+ var isTextAsset = (t) => t === "text" || t === "shader" || t === "scene";
587
+ var computeServerDiff = (project, scan) => {
588
+ const pathById = buildPaths(project.assets);
589
+ const serverByPath = new Map;
590
+ for (const asset of project.assets) {
591
+ if (asset.type === "folder")
592
+ continue;
593
+ serverByPath.set(pathById.get(asset.id), asset);
594
+ }
595
+ const localByPath = new Map(scan.files.map((f) => [f.path, f]));
596
+ const entries = [];
597
+ const paths = new Set([...serverByPath.keys(), ...localByPath.keys()]);
598
+ for (const path of [...paths].sort()) {
599
+ const server = serverByPath.get(path);
600
+ const local = localByPath.get(path);
601
+ if (local && !server) {
602
+ const text = isTextKind(classifyKind(basename(path)));
603
+ entries.push({ path, status: "added", binary: !text, serverText: "", localText: text ? readFileSync5(local.absPath, "utf8") : undefined });
604
+ continue;
605
+ }
606
+ if (server && !local) {
607
+ const text = isTextAsset(server.type);
608
+ entries.push({ path, status: "removed", binary: !text, serverText: text ? server.text ?? "" : undefined, localText: "" });
609
+ continue;
610
+ }
611
+ if (server && local) {
612
+ if (isTextAsset(server.type)) {
613
+ const serverText = server.text ?? "";
614
+ const localText = readFileSync5(local.absPath, "utf8");
615
+ if (serverText !== localText)
616
+ entries.push({ path, status: "modified", binary: false, serverText, localText });
617
+ } else {
618
+ const serverSize = server.file?.size;
619
+ const localSize = Buffer.byteLength(readFileSync5(local.absPath));
620
+ if (serverSize !== undefined && serverSize !== localSize) {
621
+ entries.push({ path, status: "modified", binary: true });
622
+ }
623
+ }
624
+ }
625
+ }
626
+ return entries;
627
+ };
628
+
629
+ // src/textDiff.ts
630
+ var MAX_LINES = 4000;
631
+ var lcsDiff = (a, b) => {
632
+ const n = a.length, m = b.length;
633
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
634
+ for (let i2 = n - 1;i2 >= 0; i2--) {
635
+ for (let j2 = m - 1;j2 >= 0; j2--) {
636
+ dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
637
+ }
638
+ }
639
+ const ops = [];
640
+ let i = 0, j = 0;
641
+ while (i < n && j < m) {
642
+ if (a[i] === b[j]) {
643
+ ops.push({ tag: " ", line: a[i] });
644
+ i++;
645
+ j++;
646
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
647
+ ops.push({ tag: "-", line: a[i] });
648
+ i++;
649
+ } else {
650
+ ops.push({ tag: "+", line: b[j] });
651
+ j++;
652
+ }
653
+ }
654
+ while (i < n)
655
+ ops.push({ tag: "-", line: a[i++] });
656
+ while (j < m)
657
+ ops.push({ tag: "+", line: b[j++] });
658
+ return ops;
659
+ };
660
+ var splitLines = (text) => {
661
+ if (text === "")
662
+ return [];
663
+ const lines = text.split(`
664
+ `);
665
+ if (lines[lines.length - 1] === "")
666
+ lines.pop();
667
+ return lines;
668
+ };
669
+ var unifiedDiff = (oldText, newText, oldLabel, newLabel, context = 3) => {
670
+ if (oldText === newText)
671
+ return "";
672
+ const a = splitLines(oldText);
673
+ const b = splitLines(newText);
674
+ if (a.length + b.length > MAX_LINES) {
675
+ return c.dim(` (large file — ${a.length} → ${b.length} lines, diff omitted)`);
676
+ }
677
+ const ops = lcsDiff(a, b);
678
+ const hunks = [];
679
+ let aLine = 0, bLine = 0;
680
+ let cur = null;
681
+ let trailingContext = 0;
682
+ for (let k = 0;k < ops.length; k++) {
683
+ const op = ops[k];
684
+ const isChange = op.tag !== " ";
685
+ if (isChange) {
686
+ if (!cur) {
687
+ const back = [];
688
+ let bi = k - 1;
689
+ while (bi >= 0 && ops[bi].tag === " " && back.length < context) {
690
+ back.unshift(ops[bi]);
691
+ bi--;
692
+ }
693
+ cur = { aStart: aLine - back.length, bStart: bLine - back.length, lines: [...back] };
694
+ }
695
+ cur.lines.push(op);
696
+ trailingContext = 0;
697
+ } else if (cur) {
698
+ cur.lines.push(op);
699
+ if (++trailingContext >= context * 2) {
700
+ cur.lines.length -= trailingContext - context;
701
+ hunks.push(cur);
702
+ cur = null;
703
+ trailingContext = 0;
704
+ }
705
+ }
706
+ if (op.tag !== "+")
707
+ aLine++;
708
+ if (op.tag !== "-")
709
+ bLine++;
710
+ }
711
+ if (cur) {
712
+ if (trailingContext > context)
713
+ cur.lines.length -= trailingContext - context;
714
+ hunks.push(cur);
715
+ }
716
+ const out = [c.bold(`--- ${oldLabel}`), c.bold(`+++ ${newLabel}`)];
717
+ for (const h of hunks) {
718
+ const aCount = h.lines.filter((o) => o.tag !== "+").length;
719
+ const bCount = h.lines.filter((o) => o.tag !== "-").length;
720
+ out.push(c.cyan(`@@ -${h.aStart + 1},${aCount} +${h.bStart + 1},${bCount} @@`));
721
+ for (const o of h.lines) {
722
+ if (o.tag === "+")
723
+ out.push(c.green("+" + o.line));
724
+ else if (o.tag === "-")
725
+ out.push(c.red("-" + o.line));
726
+ else
727
+ out.push(c.dim(" " + o.line));
728
+ }
729
+ }
730
+ return out.join(`
731
+ `);
732
+ };
733
+
734
+ // src/commands/diff.ts
735
+ var diff = async (args) => {
736
+ const root = findProjectRoot(process.cwd());
737
+ const manifest = readManifest(root);
738
+ const config = loadConfig();
739
+ const apiUrl = requireApiUrl(config);
740
+ const token = requireToken(config);
741
+ info(c.dim("Comparing with the server…"));
742
+ const project = await getProject(apiUrl, token, manifest.uuid);
743
+ const scan = scanProject(root, manifest);
744
+ let entries = computeServerDiff(project, scan);
745
+ if (args._.length > 0) {
746
+ const keys = args._.map(toKey);
747
+ entries = entries.filter((e) => keys.some((k) => e.path === k || e.path.startsWith(k + "/")));
748
+ }
749
+ if (entries.length === 0) {
750
+ info(c.dim("No differences between local and server."));
751
+ return;
752
+ }
753
+ if (flagBool(args, "name-only")) {
754
+ for (const e of entries)
755
+ log(statusLabel(e) + e.path + (e.binary ? c.dim(" (binary)") : ""));
756
+ return;
757
+ }
758
+ for (const e of entries) {
759
+ if (e.binary) {
760
+ log(statusLabel(e) + e.path + c.dim(" (binary — content not shown)"));
761
+ continue;
762
+ }
763
+ const d = unifiedDiff(e.serverText ?? "", e.localText ?? "", `server${e.path}`, `local${e.path}`);
764
+ if (d)
765
+ log(`
766
+ ` + d);
767
+ }
768
+ };
769
+ var statusLabel = (e) => e.status === "added" ? c.green(" added ") : e.status === "removed" ? c.red(" removed ") : c.yellow(" modified ");
770
+
368
771
  // src/browserAuth.ts
369
772
  import { createServer } from "node:http";
370
773
  import { spawn } from "node:child_process";
@@ -455,101 +858,12 @@ var login = async (args) => {
455
858
  };
456
859
 
457
860
  // src/commands/preview.ts
458
- import { writeFileSync as writeFileSync5 } from "node:fs";
861
+ import { writeFileSync as writeFileSync6 } from "node:fs";
459
862
  import { resolve as resolve2 } from "node:path";
460
863
 
461
864
  // src/compile/collect.ts
462
- import { readFileSync as readFileSync4 } from "node:fs";
463
- import { basename } from "node:path";
464
-
465
- // src/localFiles.ts
466
- import { readdirSync as readdirSync2, readFileSync as readFileSync3 } from "node:fs";
467
- import { join as join5, relative } from "node:path";
468
- var SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"]);
469
- var SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".DS_Store"]);
470
- var TEXT_EXTS = new Set([
471
- "ts",
472
- "tsx",
473
- "js",
474
- "jsx",
475
- "mjs",
476
- "cjs",
477
- "json",
478
- "svg",
479
- "glsl",
480
- "vert",
481
- "frag",
482
- "txt",
483
- "md",
484
- "css",
485
- "html",
486
- "xml",
487
- "yml",
488
- "yaml",
489
- "csv"
490
- ]);
491
- var ext = (name) => {
492
- const i = name.lastIndexOf(".");
493
- return i < 0 ? "" : name.slice(i + 1).toLowerCase();
494
- };
495
- var classifyKind = (name) => {
496
- const e = ext(name);
497
- if (e === "mat")
498
- return "shader";
499
- if (e === "scene")
500
- return "scene";
501
- return TEXT_EXTS.has(e) ? "text" : "resource";
502
- };
503
- var isTextKind = (kind) => kind !== "resource";
504
- var scanLocal = (root) => {
505
- const files = [];
506
- const dirs = [];
507
- const walk = (dir) => {
508
- for (const entry of readdirSync2(dir, { withFileTypes: true })) {
509
- if (entry.isDirectory()) {
510
- if (SKIP_DIRS.has(entry.name))
511
- continue;
512
- const abs = join5(dir, entry.name);
513
- dirs.push(toKey(relative(root, abs)));
514
- walk(abs);
515
- } else if (entry.isFile()) {
516
- if (SKIP_FILES.has(entry.name))
517
- continue;
518
- const abs = join5(dir, entry.name);
519
- const bytes = readFileSync3(abs);
520
- files.push({ path: toKey(relative(root, abs)), absPath: abs, sha: sha256(bytes) });
521
- }
522
- }
523
- };
524
- walk(root);
525
- return { files, dirs };
526
- };
527
- var diffLocal = (manifest, scan) => {
528
- const added = [];
529
- const modified = [];
530
- const localByPath = new Map(scan.files.map((f) => [f.path, f]));
531
- for (const file of scan.files) {
532
- const known = manifest.files[file.path];
533
- if (!known)
534
- added.push(file);
535
- else if (known.sha !== file.sha)
536
- modified.push(file);
537
- }
538
- const deleted = Object.keys(manifest.files).filter((path) => !localByPath.has(path));
539
- const moves = new Map;
540
- const claimed = new Set;
541
- for (const file of added) {
542
- const from = deleted.find((path) => !claimed.has(path) && manifest.files[path].sha === file.sha);
543
- if (from) {
544
- moves.set(file.path, from);
545
- claimed.add(from);
546
- }
547
- }
548
- return { added, modified, deleted, moves };
549
- };
550
- var hasChanges = (diff) => diff.added.length + diff.modified.length + diff.deleted.length > 0;
551
-
552
- // src/compile/collect.ts
865
+ import { readFileSync as readFileSync6 } from "node:fs";
866
+ import { basename as basename2 } from "node:path";
553
867
  var collectEntries = (root, manifest, project) => {
554
868
  const pathById = buildPaths(project.assets);
555
869
  const serverByPath = new Map;
@@ -561,7 +875,7 @@ var collectEntries = (root, manifest, project) => {
561
875
  const entries = [];
562
876
  const warnings = [];
563
877
  for (const file of scanLocal(root).files) {
564
- const type = manifest.files[file.path]?.type ?? classifyKind(basename(file.path));
878
+ const type = manifest.files[file.path]?.type ?? classifyKind(basename2(file.path));
565
879
  if (type === "resource") {
566
880
  const src = serverByPath.get(file.path)?.fileSrc;
567
881
  if (!src)
@@ -573,7 +887,7 @@ var collectEntries = (root, manifest, project) => {
573
887
  warnings.push(`No compiled shader for ${file.path} — push it to compile the shader.`);
574
888
  entries.push({ path: file.path, type, shaders });
575
889
  } else {
576
- entries.push({ path: file.path, type, text: readFileSync4(file.absPath, "utf8") });
890
+ entries.push({ path: file.path, type, text: readFileSync6(file.absPath, "utf8") });
577
891
  }
578
892
  }
579
893
  return { entries, warnings };
@@ -581,8 +895,8 @@ var collectEntries = (root, manifest, project) => {
581
895
 
582
896
  // src/compile/bundler.ts
583
897
  import * as esbuild from "esbuild";
584
- import { existsSync as existsSync3 } from "node:fs";
585
- import { dirname as dirname3, join as join6 } from "node:path";
898
+ import { existsSync as existsSync4 } from "node:fs";
899
+ import { dirname as dirname3, join as join7 } from "node:path";
586
900
  import { fileURLToPath } from "node:url";
587
901
 
588
902
  // src/compile/assetMacro.ts
@@ -625,32 +939,32 @@ import * as vec4 from "gl-matrix/vec4";
625
939
  `;
626
940
  var resolveRuntime = () => {
627
941
  const here = dirname3(fileURLToPath(import.meta.url));
628
- const vendored = join6(here, "..", "runtime");
629
- if (existsSync3(join6(vendored, "worker", "creator.js"))) {
630
- const w = join6(vendored, "worker");
942
+ const vendored = join7(here, "..", "runtime");
943
+ if (existsSync4(join7(vendored, "worker", "creator.js"))) {
944
+ const w = join7(vendored, "worker");
631
945
  return {
632
946
  alias: {
633
- creator: join6(w, "creator.js"),
634
- ui: join6(w, "ui.js"),
635
- animate: join6(w, "animate.js"),
636
- utils: join6(w, "utils.js"),
637
- "gl-matrix": join6(vendored, "gl-matrix")
947
+ creator: join7(w, "creator.js"),
948
+ ui: join7(w, "ui.js"),
949
+ animate: join7(w, "animate.js"),
950
+ utils: join7(w, "utils.js"),
951
+ "gl-matrix": join7(vendored, "gl-matrix")
638
952
  },
639
- inject: [join6(w, "globals.js")]
953
+ inject: [join7(w, "globals.js")]
640
954
  };
641
955
  }
642
- const repoRoot = join6(here, "..", "..", "..", "..");
643
- const ws = join6(repoRoot, "packages", "worker", "src");
644
- if (existsSync3(join6(ws, "index.ts"))) {
956
+ const repoRoot = join7(here, "..", "..", "..", "..");
957
+ const ws = join7(repoRoot, "packages", "worker", "src");
958
+ if (existsSync4(join7(ws, "index.ts"))) {
645
959
  return {
646
960
  alias: {
647
- creator: join6(ws, "index.ts"),
648
- ui: join6(ws, "ui.ts"),
649
- animate: join6(ws, "animate.ts"),
650
- utils: join6(ws, "utils.ts"),
651
- "gl-matrix": join6(repoRoot, "packages", "backend", "src", "assets", "dependencies", "gl-matrix")
961
+ creator: join7(ws, "index.ts"),
962
+ ui: join7(ws, "ui.ts"),
963
+ animate: join7(ws, "animate.ts"),
964
+ utils: join7(ws, "utils.ts"),
965
+ "gl-matrix": join7(repoRoot, "packages", "backend", "src", "assets", "dependencies", "gl-matrix")
652
966
  },
653
- inject: [join6(ws, "globals.ts")]
967
+ inject: [join7(ws, "globals.ts")]
654
968
  };
655
969
  }
656
970
  throw new CliError("Could not locate the compile runtime — try reinstalling lecodes-cli.");
@@ -833,8 +1147,10 @@ var preview = async (args) => {
833
1147
  for (const w of warnings)
834
1148
  warnErr(w);
835
1149
  const out = flagStr(args, "o", "out");
836
- if (out) {
837
- writeFileSync5(resolve2(out), js);
1150
+ if (out && isNullSink(out)) {
1151
+ note(`Discarded output (${out})`);
1152
+ } else if (out) {
1153
+ writeFileSync6(resolve2(out), js);
838
1154
  note(`Wrote ${out} (${js.length} bytes)`);
839
1155
  } else {
840
1156
  process.stdout.write(js);
@@ -846,9 +1162,37 @@ var preview = async (args) => {
846
1162
  };
847
1163
 
848
1164
  // src/commands/render.ts
849
- import { writeFileSync as writeFileSync6 } from "node:fs";
1165
+ import { writeFileSync as writeFileSync7 } from "node:fs";
850
1166
  import { resolve as resolve3 } from "node:path";
1167
+ var DEVICE_SAFE_AREA = {
1168
+ none: [0, 0, 0, 0],
1169
+ iphone: [47, 0, 34, 0],
1170
+ android: [24, 0, 24, 0]
1171
+ };
1172
+ var resolveSafeArea = (args) => {
1173
+ const explicit = flagStr(args, "safe-area");
1174
+ if (explicit) {
1175
+ const n = explicit.split(",").map((s) => Number(s.trim()));
1176
+ if (n.length === 4 && n.every((v) => Number.isFinite(v)))
1177
+ return n;
1178
+ throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
1179
+ }
1180
+ const device = flagStr(args, "device");
1181
+ if (device) {
1182
+ const preset = DEVICE_SAFE_AREA[device];
1183
+ if (!preset)
1184
+ throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
1185
+ return preset;
1186
+ }
1187
+ return;
1188
+ };
851
1189
  var render = async (args) => {
1190
+ const timeoutMs = Number(flagStr(args, "timeout")) || 15000;
1191
+ const killer = setTimeout(() => {
1192
+ warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`);
1193
+ process.exit(1);
1194
+ }, timeoutMs);
1195
+ killer.unref?.();
852
1196
  const root = findProjectRoot(process.cwd());
853
1197
  const manifest = readManifest(root);
854
1198
  const config = loadConfig();
@@ -871,54 +1215,81 @@ var render = async (args) => {
871
1215
  }
872
1216
  let renderer;
873
1217
  try {
874
- renderer = await import("lecodes-render");
1218
+ renderer = await import("lecodes-renderer/headless");
875
1219
  } catch {
876
- throw new CliError("The renderer isn't installed. Run: npm i -D lecodes-render");
1220
+ throw new CliError("The renderer isn't installed. Run: npm i -D lecodes-renderer");
877
1221
  }
878
1222
  const width = Number(flagStr(args, "width")) || 390;
879
1223
  const height = Number(flagStr(args, "height")) || 844;
1224
+ const settleStr = flagStr(args, "settle");
1225
+ const timeStr = flagStr(args, "time");
1226
+ const onConsole = flagBool(args, "logs") ? (level, text2) => logErr(`${c.dim(`[${level}]`)} ${text2}`) : undefined;
1227
+ const timing = {
1228
+ settleMs: settleStr !== undefined ? Number(settleStr) : undefined,
1229
+ waitNetwork: flagBool(args, "wait-network"),
1230
+ timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
1231
+ afterAnimations: !flagBool(args, "no-after-animations"),
1232
+ safeArea: resolveSafeArea(args),
1233
+ onConsole
1234
+ };
1235
+ const done = (flush) => flush(() => {
1236
+ clearTimeout(killer);
1237
+ process.exit(0);
1238
+ });
880
1239
  const pngFlag = flagStr(args, "png") ?? (flagBool(args, "png") ? "screenshot.png" : undefined);
881
1240
  if (pngFlag !== undefined) {
882
1241
  note("Rendering PNG…");
883
1242
  const { png, warnings: pngWarnings } = await renderer.renderToPng(js, {
884
1243
  width,
885
1244
  height,
886
- scale: Number(flagStr(args, "scale")) || 2
1245
+ scale: Number(flagStr(args, "scale")) || 2,
1246
+ ...timing
887
1247
  });
888
- const pngPath = resolve3(pngFlag);
889
- writeFileSync6(pngPath, png);
890
1248
  for (const w of [...warnings, ...pngWarnings])
891
1249
  warnErr(w);
892
- note(`Wrote ${pngFlag} (${png.length} bytes, ${width}x${height})`);
893
- return;
1250
+ if (isNullSink(pngFlag)) {
1251
+ note(`Discarded PNG (${pngFlag})`);
1252
+ } else {
1253
+ writeFileSync7(resolve3(pngFlag), png);
1254
+ note(`Wrote ${pngFlag} (${png.length} bytes, ${width}x${height})`);
1255
+ }
1256
+ return done((cb) => cb());
894
1257
  }
895
1258
  note("Rendering…");
896
- const result = await renderer.renderToJson(js, { width, height });
1259
+ const result = await renderer.renderToJson(js, { width, height, ...timing });
897
1260
  for (const w of [...warnings, ...result.warnings])
898
1261
  warnErr(w);
899
1262
  const out = flagStr(args, "o", "out");
1263
+ if (out && isNullSink(out)) {
1264
+ note(`Discarded output (${out})`);
1265
+ return done((cb) => cb());
1266
+ }
900
1267
  if (out) {
901
- writeFileSync6(resolve3(out), result.text);
1268
+ writeFileSync7(resolve3(out), result.text);
902
1269
  note(`Wrote ${out} (${result.text.length} bytes)`);
903
- } else {
904
- process.stdout.write(result.text);
905
- if (!result.text.endsWith(`
906
- `))
907
- process.stdout.write(`
908
- `);
1270
+ return done((cb) => cb());
909
1271
  }
1272
+ const text = result.text.endsWith(`
1273
+ `) ? result.text : result.text + `
1274
+ `;
1275
+ return done((cb) => process.stdout.write(text, cb));
910
1276
  };
911
1277
 
912
1278
  // src/commands/pull.ts
913
- import { existsSync as existsSync4, rmSync } from "node:fs";
914
- import { join as join7 } from "node:path";
1279
+ import { existsSync as existsSync5, mkdirSync as mkdirSync6, rmSync, writeFileSync as writeFileSync8 } from "node:fs";
1280
+ import { dirname as dirname4, join as join8 } from "node:path";
915
1281
  var pull = async (args) => {
916
1282
  const root = findProjectRoot(process.cwd());
917
1283
  const manifest = readManifest(root);
918
1284
  const config = loadConfig();
919
1285
  const apiUrl = requireApiUrl(config);
920
1286
  const token = requireToken(config);
921
- if (!flagBool(args, "force") && hasChanges(diffLocal(manifest, scanLocal(root)))) {
1287
+ if (args._.length > 0) {
1288
+ const project2 = await getProject(apiUrl, token, manifest.uuid);
1289
+ await pullPaths(root, manifest, project2, args._.map(toKey), apiUrl, token);
1290
+ return;
1291
+ }
1292
+ if (!flagBool(args, "force") && hasChanges(diffLocal(manifest, scanProject(root, manifest)))) {
922
1293
  throw new CliError("You have local changes. Push them first, or run `lecodes pull --force` to discard them.");
923
1294
  }
924
1295
  info(c.dim("Fetching project…"));
@@ -926,8 +1297,8 @@ var pull = async (args) => {
926
1297
  const remotePaths = new Set([...buildPaths(project.assets).entries()].filter(([id]) => project.assets.find((a) => a.id === id)?.type !== "folder").map(([, path]) => path));
927
1298
  for (const path of Object.keys(manifest.files)) {
928
1299
  if (!remotePaths.has(path)) {
929
- const abs = join7(root, ...path.replace(/^\//, "").split("/"));
930
- if (existsSync4(abs))
1300
+ const abs = join8(root, ...path.replace(/^\//, "").split("/"));
1301
+ if (existsSync5(abs))
931
1302
  rmSync(abs);
932
1303
  }
933
1304
  }
@@ -941,42 +1312,110 @@ var pull = async (args) => {
941
1312
  writeManifest(root, { ...manifest, name: project.name, baseHeadId: commits.headId, files });
942
1313
  success(`Pulled latest into ${c.bold(manifest.name)}.`);
943
1314
  };
1315
+ var kindOf2 = (type) => type === "file" ? "resource" : type;
1316
+ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
1317
+ const pathById = buildPaths(project.assets);
1318
+ const byPath = new Map;
1319
+ for (const asset of project.assets)
1320
+ byPath.set(pathById.get(asset.id), asset);
1321
+ const targets = [];
1322
+ for (const path of paths) {
1323
+ const asset = byPath.get(path);
1324
+ if (!asset)
1325
+ throw new CliError(`Not found on the server: ${path}`);
1326
+ if (asset.type === "folder") {
1327
+ const prefix = path + "/";
1328
+ const under = project.assets.filter((a) => a.type !== "folder" && pathById.get(a.id).startsWith(prefix));
1329
+ if (under.length === 0)
1330
+ throw new CliError(`Folder is empty on the server: ${path}`);
1331
+ targets.push(...under);
1332
+ } else {
1333
+ targets.push(asset);
1334
+ }
1335
+ }
1336
+ const files = { ...manifest.files };
1337
+ for (const asset of targets) {
1338
+ const key = pathById.get(asset.id);
1339
+ const abs = join8(root, ...key.replace(/^\//, "").split("/"));
1340
+ mkdirSync6(dirname4(abs), { recursive: true });
1341
+ let sha;
1342
+ if (asset.type === "file") {
1343
+ if (!asset.file?.src)
1344
+ continue;
1345
+ const bytes = await downloadBlob(apiUrl, asset.file.src);
1346
+ writeFileSync8(abs, bytes);
1347
+ sha = sha256(bytes);
1348
+ } else {
1349
+ const text = asset.text ?? "";
1350
+ writeFileSync8(abs, text);
1351
+ sha = sha256(text);
1352
+ }
1353
+ files[key] = { id: asset.id, type: kindOf2(asset.type), sha };
1354
+ info(c.green(` pulled ${key}`));
1355
+ }
1356
+ writeManifest(root, { ...manifest, files });
1357
+ success(`Pulled ${targets.length} file${targets.length === 1 ? "" : "s"} from the server.`);
1358
+ info(c.dim("Local edits to other files are untouched. Run `lecodes diff` to review, then `lecodes push --force`."));
1359
+ };
944
1360
 
945
1361
  // src/commands/push.ts
946
- import { basename as basename2 } from "node:path";
947
- import { readFileSync as readFileSync5 } from "node:fs";
1362
+ import { basename as basename3 } from "node:path";
1363
+ import { readFileSync as readFileSync7 } from "node:fs";
948
1364
  var push = async (args) => {
949
1365
  const root = findProjectRoot(process.cwd());
950
1366
  const manifest = readManifest(root);
951
1367
  const config = loadConfig();
952
1368
  const apiUrl = requireApiUrl(config);
953
1369
  const token = requireToken(config);
954
- const scan = scanLocal(root);
955
- const diff = diffLocal(manifest, scan);
956
- if (!hasChanges(diff)) {
957
- info(c.dim("Working tree clean — nothing to push."));
1370
+ const publish = flagBool(args, "publish");
1371
+ const scan = scanProject(root, manifest);
1372
+ const diff2 = diffLocal(manifest, scan);
1373
+ if (!hasChanges(diff2)) {
1374
+ if (publish) {
1375
+ await publishProject(apiUrl, token, manifest.uuid);
1376
+ success("No file changes — published the current project state.");
1377
+ } else {
1378
+ info(c.dim("Working tree clean — nothing to push."));
1379
+ }
958
1380
  return;
959
1381
  }
960
- if (!flagBool(args, "force") && manifest.baseHeadId !== null) {
1382
+ const force = flagBool(args, "force");
1383
+ if (!force && manifest.baseHeadId !== null) {
961
1384
  const commits = await getCommits(apiUrl, token, manifest.uuid);
962
1385
  if (commits.headId !== manifest.baseHeadId) {
963
- throw new CliError("The project changed on the server since your last sync. Run `lecodes pull` first, or `lecodes push --force` to overwrite.");
1386
+ throw new CliError("The project changed on the server since your last sync. Run `lecodes diff` to compare, `lecodes pull` to take the server's version, or `lecodes push --force` to overwrite.");
1387
+ }
1388
+ }
1389
+ if (force) {
1390
+ const project = await getProject(apiUrl, token, manifest.uuid);
1391
+ const serverDiff = computeServerDiff(project, scan);
1392
+ if (serverDiff.length) {
1393
+ info(c.yellow(`--force will overwrite the server. ${serverDiff.length} file${serverDiff.length === 1 ? "" : "s"} change:`));
1394
+ const label = { added: c.green(" create "), removed: c.red(" delete "), modified: c.yellow(" update ") };
1395
+ for (const e of serverDiff)
1396
+ log(label[e.status] + e.path + (e.binary ? c.dim(" (binary)") : ""));
1397
+ if (process.stdin.isTTY && !flagBool(args, "y", "yes")) {
1398
+ if (!await confirm("Overwrite the server with your local copy?", false)) {
1399
+ info(c.dim("Aborted."));
1400
+ return;
1401
+ }
1402
+ }
964
1403
  }
965
1404
  }
966
1405
  const message = (flagStr(args, "m", "message") ?? await prompt("Checkpoint message")) || "Update from lecodes CLI";
967
- const sourceFor = (path) => manifest.files[path] ?? (diff.moves.has(path) ? manifest.files[diff.moves.get(path)] : undefined);
1406
+ const sourceFor = (path) => manifest.files[path] ?? (diff2.moves.has(path) ? manifest.files[diff2.moves.get(path)] : undefined);
968
1407
  const files = [];
969
1408
  let uploaded = 0;
970
1409
  for (const file of scan.files) {
971
1410
  const source = sourceFor(file.path);
972
- const kind = source?.type ?? classifyKind(basename2(file.path));
1411
+ const kind = source?.type ?? classifyKind(basename3(file.path));
973
1412
  const entry = { path: file.path, type: kind };
974
1413
  if (source)
975
1414
  entry.assetId = source.id;
976
1415
  if (isTextKind(kind)) {
977
- entry.text = readFileSync5(file.absPath, "utf8");
1416
+ entry.text = readFileSync7(file.absPath, "utf8");
978
1417
  } else if (!source || source.sha !== file.sha) {
979
- const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename2(file.path), readFileSync5(file.absPath));
1418
+ const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename3(file.path), readFileSync7(file.absPath));
980
1419
  entry.blobFileId = blob.id;
981
1420
  uploaded++;
982
1421
  }
@@ -985,9 +1424,11 @@ var push = async (args) => {
985
1424
  const folders = scan.dirs.map((path) => ({ path }));
986
1425
  if (uploaded > 0)
987
1426
  info(c.dim(`Uploaded ${uploaded} binary file${uploaded === 1 ? "" : "s"}.`));
988
- const res = await sync(apiUrl, token, manifest.uuid, { message, files, folders });
1427
+ const res = await sync(apiUrl, token, manifest.uuid, { message, files, folders, publish });
989
1428
  if (!res.created) {
990
1429
  info(c.dim("Server reported no changes."));
1430
+ if (publish)
1431
+ reportPublish(res);
991
1432
  return;
992
1433
  }
993
1434
  const shaByPath = new Map(scan.files.map((f) => [f.path, f.sha]));
@@ -997,37 +1438,49 @@ var push = async (args) => {
997
1438
  }
998
1439
  writeManifest(root, { ...manifest, baseHeadId: res.headId, files: newFiles });
999
1440
  const counts = [
1000
- diff.added.length - diff.moves.size && `${diff.added.length - diff.moves.size} added`,
1001
- diff.modified.length && `${diff.modified.length} modified`,
1002
- diff.moves.size && `${diff.moves.size} moved`,
1003
- diff.deleted.length - diff.moves.size && `${diff.deleted.length - diff.moves.size} deleted`
1441
+ diff2.added.length - diff2.moves.size && `${diff2.added.length - diff2.moves.size} added`,
1442
+ diff2.modified.length && `${diff2.modified.length} modified`,
1443
+ diff2.moves.size && `${diff2.moves.size} moved`,
1444
+ diff2.deleted.length - diff2.moves.size && `${diff2.deleted.length - diff2.moves.size} deleted`
1004
1445
  ].filter(Boolean).join(", ");
1005
1446
  success(`Pushed checkpoint${res.seq ? ` #${res.seq}` : ""}${counts ? ` (${counts})` : ""}: ${message}`);
1447
+ if (publish)
1448
+ reportPublish(res);
1449
+ };
1450
+ var reportPublish = (res) => {
1451
+ if (res.published)
1452
+ success("Published — the project is now live.");
1453
+ else
1454
+ warn(`Push landed, but publish failed: ${res.publishError ?? "unknown error"}`);
1006
1455
  };
1007
1456
 
1008
1457
  // src/commands/status.ts
1009
1458
  var status = async (_args) => {
1010
1459
  const root = findProjectRoot(process.cwd());
1011
1460
  const manifest = readManifest(root);
1012
- const diff = diffLocal(manifest, scanLocal(root));
1461
+ const scan = scanProject(root, manifest);
1462
+ const diff2 = diffLocal(manifest, scan);
1013
1463
  info(`Project ${c.bold(manifest.name)} (${manifest.uuid})`);
1014
- if (!hasChanges(diff)) {
1015
- info(c.dim("Working tree clean — nothing to push."));
1464
+ const ignoredNote = scan.ignored.length ? c.dim(` (${scan.ignored.length} ignored)`) : "";
1465
+ if (!hasChanges(diff2)) {
1466
+ info(c.dim("Working tree clean — nothing to push.") + ignoredNote);
1016
1467
  return;
1017
1468
  }
1018
- for (const f of diff.added) {
1019
- if (!diff.moves.has(f.path))
1469
+ for (const f of diff2.added) {
1470
+ if (!diff2.moves.has(f.path))
1020
1471
  log(c.green(` added ${f.path}`));
1021
1472
  }
1022
- for (const f of diff.modified)
1473
+ for (const f of diff2.modified)
1023
1474
  log(c.yellow(` modified ${f.path}`));
1024
- for (const [to, from] of diff.moves)
1475
+ for (const [to, from] of diff2.moves)
1025
1476
  log(c.cyan(` moved ${from} → ${to}`));
1026
- const movedFrom = new Set(diff.moves.values());
1027
- for (const path of diff.deleted) {
1477
+ const movedFrom = new Set(diff2.moves.values());
1478
+ for (const path of diff2.deleted) {
1028
1479
  if (!movedFrom.has(path))
1029
1480
  log(c.red(` deleted ${path}`));
1030
1481
  }
1482
+ if (scan.ignored.length)
1483
+ info(c.dim(` ${scan.ignored.length} ignored (.lecodesignore)`));
1031
1484
  };
1032
1485
 
1033
1486
  // src/index.ts
@@ -1043,27 +1496,44 @@ ${c.bold("Commands:")}
1043
1496
  clone <uuid|url> [dir] Download a project's files + types into a new folder
1044
1497
  --no-types skip the .d.ts / tsconfig generation
1045
1498
  status Show local changes vs the last sync
1046
- pull Overwrite local files with the project's current state
1499
+ diff [path…] Show a unified diff of local vs the current server state
1500
+ --name-only list changed paths + status only
1501
+ pull [path…] Overwrite local files with the project's current state
1502
+ (with path… : pull only those files/folders, keep other edits)
1047
1503
  --force discard uncommitted local changes
1048
1504
  push Push local changes as one new checkpoint
1049
1505
  -m, --message checkpoint message
1050
- --force push even if the server moved on
1506
+ --publish also deploy the project live (compile) with the push
1507
+ --force push even if the server moved on (shows + confirms
1508
+ which server files get overwritten)
1509
+ -y, --yes skip the --force overwrite confirmation
1051
1510
  preview Compile the local project to JS (prints to stdout)
1052
1511
  -o, --out <file> write to a file instead of stdout
1053
1512
  --entry <path> override the entrypoint
1054
1513
  --no-header omit the metadata header
1055
1514
  --public-url <url> asset URL prefix (default: the API origin)
1056
1515
  render Render the local UI project to a semantic JSON description
1057
- (needs the optional 'lecodes-render' package; UI projects only)
1516
+ (needs the optional 'lecodes-renderer' package; UI projects only)
1058
1517
  -o, --out <file> write JSON to a file instead of stdout
1059
1518
  --png [file] rasterize a PNG (default screenshot.png;
1060
1519
  needs @napi-rs/canvas)
1061
1520
  --scale <n> PNG device-pixel scale (default 2)
1062
1521
  --width/--height viewport size (default 390x844)
1063
1522
  --entry <path> override the entrypoint
1523
+ --settle <ms> wait for promises/timers/fetch before snapshot
1524
+ --wait-network let data fetches hit the real network
1525
+ --logs forward the app's console output (to stderr)
1064
1526
 
1065
1527
  Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKEN).`;
1066
- var commands = { login, clone, status, pull, push, preview, render };
1528
+ var commands = { login, clone, status, diff, pull, push, preview, render };
1529
+ var readVersion = () => {
1530
+ try {
1531
+ const here = dirname5(fileURLToPath2(import.meta.url));
1532
+ return JSON.parse(readFileSync8(join9(here, "..", "package.json"), "utf8")).version ?? "unknown";
1533
+ } catch {
1534
+ return "unknown";
1535
+ }
1536
+ };
1067
1537
  var main = async () => {
1068
1538
  const argv = process.argv.slice(2);
1069
1539
  const command = argv[0];
@@ -1071,6 +1541,10 @@ var main = async () => {
1071
1541
  log(HELP);
1072
1542
  return;
1073
1543
  }
1544
+ if (command === "--version" || command === "-v" || command === "version") {
1545
+ log(readVersion());
1546
+ return;
1547
+ }
1074
1548
  const run = commands[command];
1075
1549
  if (!run) {
1076
1550
  log(c.red(`Unknown command: ${command}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Clone, edit and push LeCodes projects from your machine.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "scripts": {
23
23
  "vendor-runtime": "bun scripts/vendor-runtime.ts",
24
- "build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external esbuild --external lecodes-render --banner=\"#!/usr/bin/env node\"",
24
+ "build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external esbuild --external lecodes-renderer --banner=\"#!/usr/bin/env node\"",
25
25
  "typecheck": "tsc --noEmit",
26
26
  "dev": "bun run src/index.ts",
27
27
  "prepublishOnly": "bun run build"
@@ -30,14 +30,14 @@
30
30
  "esbuild": "^0.27.3"
31
31
  },
32
32
  "peerDependencies": {
33
- "lecodes-render": "*"
33
+ "lecodes-renderer": "^0.1.1"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
- "lecodes-render": {
36
+ "lecodes-renderer": {
37
37
  "optional": true
38
38
  }
39
39
  },
40
40
  "devDependencies": {
41
- "lecodes-render": "workspace:*"
41
+ "lecodes-renderer": "0.1.1"
42
42
  }
43
43
  }
@@ -713,8 +713,8 @@ var Router = /* @__PURE__ */ Object.freeze({
713
713
  push(screen) {
714
714
  _creatorUI.routerPush(screen);
715
715
  },
716
- replace(screen) {
717
- _creatorUI.routerReplace(screen);
716
+ replace(screen, opts) {
717
+ _creatorUI.routerReplace(screen, opts?.transition ?? "fade");
718
718
  },
719
719
  pop(to) {
720
720
  _creatorUI.routerPop(to ?? -1);