lecodes-cli 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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 dirname7, join as join11 } from "node:path";
8
+ import { fileURLToPath as fileURLToPath3 } 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++) {
@@ -146,6 +160,7 @@ var apiRequest = async (apiUrl, path, opts = {}) => {
146
160
  };
147
161
  var getAccount = (apiUrl, token) => apiRequest(apiUrl, "/account", { token });
148
162
  var getProject = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}`, { token });
163
+ var createProject = (apiUrl, token, name, teamUuid) => apiRequest(apiUrl, "/projects", { token, body: { name, teamUuid } });
149
164
  var getCommits = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}/commits`, { token });
150
165
  var getTypes = (apiUrl) => apiRequest(apiUrl, "/types");
151
166
  var uploadBlob = (apiUrl, token, uuid, name, bytes) => {
@@ -154,6 +169,7 @@ var uploadBlob = (apiUrl, token, uuid, name, bytes) => {
154
169
  return apiRequest(apiUrl, `/projects/${uuid}/blobs`, { token, body: form });
155
170
  };
156
171
  var sync = (apiUrl, token, uuid, payload) => apiRequest(apiUrl, `/projects/${uuid}/sync`, { token, body: payload });
172
+ var publishProject = (apiUrl, token, uuid) => apiRequest(apiUrl, `/projects/${uuid}/publish`, { token, body: {} });
157
173
  var downloadBlob = async (apiUrl, src) => {
158
174
  const resp = await fetch(`${apiUrl}${src}`);
159
175
  if (!resp.ok)
@@ -267,38 +283,8 @@ var materializeAssets = async (root, project, apiUrl, token, onProgress) => {
267
283
  };
268
284
 
269
285
  // src/types.ts
270
- import { join as join4 } from "node:path";
286
+ import { dirname as dirname3, join as join4 } from "node:path";
271
287
  import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "node:fs";
272
- var wrapGlobal = (text) => {
273
- const marker = text.indexOf("declare global");
274
- if (marker < 0)
275
- return text;
276
- const importLines = [];
277
- const bodyLines = [];
278
- for (const line of text.split(`
279
- `)) {
280
- if (/^\s*import\s.*\sfrom\s/.test(line))
281
- importLines.push(line);
282
- else
283
- bodyLines.push(line);
284
- }
285
- const rest = bodyLines.join(`
286
- `);
287
- const open = rest.indexOf("{", rest.indexOf("declare global"));
288
- const close = rest.lastIndexOf("}");
289
- const preamble = rest.slice(0, rest.indexOf("declare global"));
290
- const inner = rest.slice(open + 1, close);
291
- const stripDeclare = (s) => s.replace(/(^|\n)([ \t]*)declare\s+/g, "$1$2");
292
- const merged = stripDeclare(preamble) + `
293
- ` + stripDeclare(inner);
294
- return `${importLines.join(`
295
- `)}
296
-
297
- declare global {
298
- ${merged}
299
- }
300
- `;
301
- };
302
288
  var TSCONFIG = {
303
289
  compilerOptions: {
304
290
  target: "ES2020",
@@ -318,16 +304,106 @@ var TSCONFIG = {
318
304
  var materializeTypes = async (root, apiUrl) => {
319
305
  const bundle = await getTypes(apiUrl);
320
306
  const typesDir = join4(root, LECODES_DIR, "types");
321
- mkdirSync4(typesDir, { recursive: true });
322
- for (const [name, text] of Object.entries(bundle)) {
323
- const content = name === "global" ? wrapGlobal(text) : text;
324
- writeFileSync4(join4(typesDir, `${name}.d.ts`), content);
307
+ for (const [path, text] of Object.entries(bundle)) {
308
+ const dest = join4(typesDir, path);
309
+ mkdirSync4(dirname3(dest), { recursive: true });
310
+ writeFileSync4(dest, text);
325
311
  }
326
312
  };
327
313
  var writeTsconfig = (root) => {
328
314
  writeFileSync4(join4(root, "tsconfig.json"), JSON.stringify(TSCONFIG, null, 2));
329
315
  };
330
316
 
317
+ // src/ignore.ts
318
+ import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync5 } from "node:fs";
319
+ import { join as join5 } from "node:path";
320
+ var BUILTIN_PATTERNS = ["node_modules/", ".git/", "_*", "*.log", ".DS_Store"];
321
+ var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
322
+ # Only untracked files are affected — assets already in the project stay tracked
323
+ # even if they match a pattern. Built-in defaults (node_modules, _*, *.log) always apply.
324
+
325
+ # Render / preview outputs
326
+ screenshot.png
327
+ shots/
328
+ *.png
329
+
330
+ # Local notes & dumps (uncomment to ignore)
331
+ # *.json
332
+ # notes.md
333
+ `;
334
+ var translate = (glob) => {
335
+ let re = "";
336
+ for (let i = 0;i < glob.length; i++) {
337
+ const ch = glob[i];
338
+ if (ch === "*") {
339
+ if (glob[i + 1] === "*") {
340
+ re += ".*";
341
+ i++;
342
+ if (glob[i + 1] === "/")
343
+ i++;
344
+ } else
345
+ re += "[^/]*";
346
+ } else if (ch === "?")
347
+ re += "[^/]";
348
+ else if (ch === "/")
349
+ re += "/";
350
+ else if ("\\^$.|+()[]{}".includes(ch))
351
+ re += "\\" + ch;
352
+ else
353
+ re += ch;
354
+ }
355
+ return re;
356
+ };
357
+ var compile = (raw) => {
358
+ let p = raw.trim();
359
+ if (!p || p.startsWith("#"))
360
+ return null;
361
+ let negate = false;
362
+ if (p.startsWith("!")) {
363
+ negate = true;
364
+ p = p.slice(1);
365
+ }
366
+ let dirOnly = false;
367
+ if (p.endsWith("/")) {
368
+ dirOnly = true;
369
+ p = p.slice(0, -1);
370
+ }
371
+ const anchored = p.startsWith("/") || p.includes("/");
372
+ if (p.startsWith("/"))
373
+ p = p.slice(1);
374
+ if (!p)
375
+ return null;
376
+ const body = translate(p);
377
+ const re = anchored ? new RegExp("^/" + body + "(/.*)?$") : new RegExp("(^|/)" + body + "(/.*)?$");
378
+ return { re, negate, dirOnly };
379
+ };
380
+ var buildMatcher = (rules) => (key, isDir) => {
381
+ let ignored = false;
382
+ for (const r of rules) {
383
+ if (r.dirOnly && !isDir)
384
+ continue;
385
+ if (r.re.test(key))
386
+ ignored = !r.negate;
387
+ }
388
+ return ignored;
389
+ };
390
+ var loadIgnore = (root) => {
391
+ const lines = [...BUILTIN_PATTERNS];
392
+ const file = join5(root, ".lecodesignore");
393
+ if (existsSync2(file)) {
394
+ try {
395
+ lines.push(...readFileSync3(file, "utf8").split(/\r?\n/));
396
+ } catch {}
397
+ }
398
+ const rules = lines.map(compile).filter((r) => r !== null);
399
+ return buildMatcher(rules);
400
+ };
401
+ var writeDefaultIgnore = (root) => {
402
+ const file = join5(root, ".lecodesignore");
403
+ if (!existsSync2(file))
404
+ writeFileSync5(file, DEFAULT_IGNORE_FILE);
405
+ };
406
+
331
407
  // src/commands/clone.ts
332
408
  var parseUuid = (input) => {
333
409
  if (!input)
@@ -347,7 +423,7 @@ var clone = async (args) => {
347
423
  const project = await getProject(apiUrl, token, uuid);
348
424
  const dir = args._[1] ?? slug(project.name);
349
425
  const root = resolve(dir);
350
- if (existsSync2(root) && readdirSync(root).length > 0) {
426
+ if (existsSync3(root) && readdirSync(root).length > 0) {
351
427
  throw new CliError(`Directory "${dir}" already exists and is not empty.`);
352
428
  }
353
429
  mkdirSync5(root, { recursive: true });
@@ -359,12 +435,348 @@ var clone = async (args) => {
359
435
  await materializeTypes(root, apiUrl);
360
436
  writeTsconfig(root);
361
437
  }
438
+ writeDefaultIgnore(root);
362
439
  const commits = await getCommits(apiUrl, token, uuid);
363
440
  writeManifest(root, { uuid, apiUrl, name: project.name, baseHeadId: commits.headId, files });
364
441
  success(`Cloned ${c.bold(project.name)} into ${c.bold(dir)}/ (${Object.keys(files).length} files)`);
365
442
  info(c.dim(` cd ${dir} — edit, then \`lecodes push -m "your message"\``));
366
443
  };
367
444
 
445
+ // src/commands/create.ts
446
+ import { existsSync as existsSync4, mkdirSync as mkdirSync6, readdirSync as readdirSync2, writeFileSync as writeFileSync6 } from "node:fs";
447
+ import { join as join6, resolve as resolve2 } from "node:path";
448
+ var slug2 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "project";
449
+ var STARTER = `// Welcome to le.codes — no imports needed, the whole SDK is available as globals.
450
+ const scene = new Scene2D({ background: '#10131a' })
451
+ scene.open()
452
+
453
+ setLoop((dt) => {
454
+ // your per-frame logic — dt is seconds since the previous frame
455
+ })
456
+ `;
457
+ var create = async (args) => {
458
+ const config = loadConfig();
459
+ const apiUrl = requireApiUrl(config);
460
+ const token = requireToken(config);
461
+ const name = args._[0];
462
+ if (!name)
463
+ throw new CliError("Usage: lecodes create <name> [directory]");
464
+ const dir = args._[1] ?? slug2(name);
465
+ const root = resolve2(dir);
466
+ if (existsSync4(root) && readdirSync2(root).length > 0) {
467
+ throw new CliError(`Directory "${dir}" already exists and is not empty.`);
468
+ }
469
+ mkdirSync6(root, { recursive: true });
470
+ info(c.dim(`Creating project ${c.bold(name)}…`));
471
+ const { uuid } = await createProject(apiUrl, token, name, flagStr(args, "team"));
472
+ info(c.dim("Fetching type definitions…"));
473
+ await materializeTypes(root, apiUrl);
474
+ writeTsconfig(root);
475
+ writeDefaultIgnore(root);
476
+ writeFileSync6(join6(root, "main.ts"), STARTER);
477
+ const commits = await getCommits(apiUrl, token, uuid);
478
+ writeManifest(root, { uuid, apiUrl, name, baseHeadId: commits.headId, files: {} });
479
+ success(`Created ${c.bold(name)} in ${c.bold(dir)}/`);
480
+ info(c.dim(` cd ${dir} — edit main.ts, then \`lecodes push -m "first commit"\` to save it`));
481
+ };
482
+
483
+ // src/localFiles.ts
484
+ import { readdirSync as readdirSync3, readFileSync as readFileSync4 } from "node:fs";
485
+ import { join as join7, relative } from "node:path";
486
+ var SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"]);
487
+ var SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"]);
488
+ var TEXT_EXTS = new Set([
489
+ "ts",
490
+ "tsx",
491
+ "js",
492
+ "jsx",
493
+ "mjs",
494
+ "cjs",
495
+ "json",
496
+ "svg",
497
+ "glsl",
498
+ "vert",
499
+ "frag",
500
+ "txt",
501
+ "md",
502
+ "css",
503
+ "html",
504
+ "xml",
505
+ "yml",
506
+ "yaml",
507
+ "csv"
508
+ ]);
509
+ var ext = (name) => {
510
+ const i = name.lastIndexOf(".");
511
+ return i < 0 ? "" : name.slice(i + 1).toLowerCase();
512
+ };
513
+ var classifyKind = (name) => {
514
+ const e = ext(name);
515
+ if (e === "mat")
516
+ return "shader";
517
+ if (e === "scene")
518
+ return "scene";
519
+ return TEXT_EXTS.has(e) ? "text" : "resource";
520
+ };
521
+ var isTextKind = (kind) => kind !== "resource";
522
+ var scanLocal = (root, opts = {}) => {
523
+ const { ignore, tracked } = opts;
524
+ const files = [];
525
+ const dirs = [];
526
+ const ignored = [];
527
+ const isTracked = (key) => tracked?.has(key) ?? false;
528
+ const hasTrackedUnder = (key) => {
529
+ if (!tracked)
530
+ return false;
531
+ const prefix = key + "/";
532
+ for (const t of tracked)
533
+ if (t.startsWith(prefix))
534
+ return true;
535
+ return false;
536
+ };
537
+ const walk = (dir, parentIgnored) => {
538
+ for (const entry of readdirSync3(dir, { withFileTypes: true })) {
539
+ const abs = join7(dir, entry.name);
540
+ const key = toKey(relative(root, abs));
541
+ if (entry.isDirectory()) {
542
+ if (SKIP_DIRS.has(entry.name))
543
+ continue;
544
+ const dirIgnored = parentIgnored || !!ignore && ignore(key, true);
545
+ if (dirIgnored && !isTracked(key) && !hasTrackedUnder(key)) {
546
+ ignored.push(key + "/");
547
+ continue;
548
+ }
549
+ dirs.push(key);
550
+ walk(abs, dirIgnored);
551
+ } else if (entry.isFile()) {
552
+ if (SKIP_FILES.has(entry.name))
553
+ continue;
554
+ const fileIgnored = parentIgnored || !!ignore && ignore(key, false);
555
+ if (fileIgnored && !isTracked(key)) {
556
+ ignored.push(key);
557
+ continue;
558
+ }
559
+ files.push({ path: key, absPath: abs, sha: sha256(readFileSync4(abs)) });
560
+ }
561
+ }
562
+ };
563
+ walk(root, false);
564
+ return { files, dirs, ignored };
565
+ };
566
+ var scanProject = (root, manifest) => scanLocal(root, { ignore: loadIgnore(root), tracked: new Set(Object.keys(manifest.files)) });
567
+ var diffLocal = (manifest, scan) => {
568
+ const added = [];
569
+ const modified = [];
570
+ const localByPath = new Map(scan.files.map((f) => [f.path, f]));
571
+ for (const file of scan.files) {
572
+ const known = manifest.files[file.path];
573
+ if (!known)
574
+ added.push(file);
575
+ else if (known.sha !== file.sha)
576
+ modified.push(file);
577
+ }
578
+ const deleted = Object.keys(manifest.files).filter((path) => !localByPath.has(path));
579
+ const moves = new Map;
580
+ const claimed = new Set;
581
+ for (const file of added) {
582
+ const from = deleted.find((path) => !claimed.has(path) && manifest.files[path].sha === file.sha);
583
+ if (from) {
584
+ moves.set(file.path, from);
585
+ claimed.add(from);
586
+ }
587
+ }
588
+ return { added, modified, deleted, moves };
589
+ };
590
+ var hasChanges = (diff) => diff.added.length + diff.modified.length + diff.deleted.length > 0;
591
+
592
+ // src/serverDiff.ts
593
+ import { readFileSync as readFileSync5 } from "node:fs";
594
+ import { basename } from "node:path";
595
+ var isTextAsset = (t) => t === "text" || t === "shader" || t === "scene";
596
+ var computeServerDiff = (project, scan) => {
597
+ const pathById = buildPaths(project.assets);
598
+ const serverByPath = new Map;
599
+ for (const asset of project.assets) {
600
+ if (asset.type === "folder")
601
+ continue;
602
+ serverByPath.set(pathById.get(asset.id), asset);
603
+ }
604
+ const localByPath = new Map(scan.files.map((f) => [f.path, f]));
605
+ const entries = [];
606
+ const paths = new Set([...serverByPath.keys(), ...localByPath.keys()]);
607
+ for (const path of [...paths].sort()) {
608
+ const server = serverByPath.get(path);
609
+ const local = localByPath.get(path);
610
+ if (local && !server) {
611
+ const text = isTextKind(classifyKind(basename(path)));
612
+ entries.push({ path, status: "added", binary: !text, serverText: "", localText: text ? readFileSync5(local.absPath, "utf8") : undefined });
613
+ continue;
614
+ }
615
+ if (server && !local) {
616
+ const text = isTextAsset(server.type);
617
+ entries.push({ path, status: "removed", binary: !text, serverText: text ? server.text ?? "" : undefined, localText: "" });
618
+ continue;
619
+ }
620
+ if (server && local) {
621
+ if (isTextAsset(server.type)) {
622
+ const serverText = server.text ?? "";
623
+ const localText = readFileSync5(local.absPath, "utf8");
624
+ if (serverText !== localText)
625
+ entries.push({ path, status: "modified", binary: false, serverText, localText });
626
+ } else {
627
+ const serverSize = server.file?.size;
628
+ const localSize = Buffer.byteLength(readFileSync5(local.absPath));
629
+ if (serverSize !== undefined && serverSize !== localSize) {
630
+ entries.push({ path, status: "modified", binary: true });
631
+ }
632
+ }
633
+ }
634
+ }
635
+ return entries;
636
+ };
637
+
638
+ // src/textDiff.ts
639
+ var MAX_LINES = 4000;
640
+ var lcsDiff = (a, b) => {
641
+ const n = a.length, m = b.length;
642
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
643
+ for (let i2 = n - 1;i2 >= 0; i2--) {
644
+ for (let j2 = m - 1;j2 >= 0; j2--) {
645
+ dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
646
+ }
647
+ }
648
+ const ops = [];
649
+ let i = 0, j = 0;
650
+ while (i < n && j < m) {
651
+ if (a[i] === b[j]) {
652
+ ops.push({ tag: " ", line: a[i] });
653
+ i++;
654
+ j++;
655
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
656
+ ops.push({ tag: "-", line: a[i] });
657
+ i++;
658
+ } else {
659
+ ops.push({ tag: "+", line: b[j] });
660
+ j++;
661
+ }
662
+ }
663
+ while (i < n)
664
+ ops.push({ tag: "-", line: a[i++] });
665
+ while (j < m)
666
+ ops.push({ tag: "+", line: b[j++] });
667
+ return ops;
668
+ };
669
+ var splitLines = (text) => {
670
+ if (text === "")
671
+ return [];
672
+ const lines = text.split(`
673
+ `);
674
+ if (lines[lines.length - 1] === "")
675
+ lines.pop();
676
+ return lines;
677
+ };
678
+ var unifiedDiff = (oldText, newText, oldLabel, newLabel, context = 3) => {
679
+ if (oldText === newText)
680
+ return "";
681
+ const a = splitLines(oldText);
682
+ const b = splitLines(newText);
683
+ if (a.length + b.length > MAX_LINES) {
684
+ return c.dim(` (large file — ${a.length} → ${b.length} lines, diff omitted)`);
685
+ }
686
+ const ops = lcsDiff(a, b);
687
+ const hunks = [];
688
+ let aLine = 0, bLine = 0;
689
+ let cur = null;
690
+ let trailingContext = 0;
691
+ for (let k = 0;k < ops.length; k++) {
692
+ const op = ops[k];
693
+ const isChange = op.tag !== " ";
694
+ if (isChange) {
695
+ if (!cur) {
696
+ const back = [];
697
+ let bi = k - 1;
698
+ while (bi >= 0 && ops[bi].tag === " " && back.length < context) {
699
+ back.unshift(ops[bi]);
700
+ bi--;
701
+ }
702
+ cur = { aStart: aLine - back.length, bStart: bLine - back.length, lines: [...back] };
703
+ }
704
+ cur.lines.push(op);
705
+ trailingContext = 0;
706
+ } else if (cur) {
707
+ cur.lines.push(op);
708
+ if (++trailingContext >= context * 2) {
709
+ cur.lines.length -= trailingContext - context;
710
+ hunks.push(cur);
711
+ cur = null;
712
+ trailingContext = 0;
713
+ }
714
+ }
715
+ if (op.tag !== "+")
716
+ aLine++;
717
+ if (op.tag !== "-")
718
+ bLine++;
719
+ }
720
+ if (cur) {
721
+ if (trailingContext > context)
722
+ cur.lines.length -= trailingContext - context;
723
+ hunks.push(cur);
724
+ }
725
+ const out = [c.bold(`--- ${oldLabel}`), c.bold(`+++ ${newLabel}`)];
726
+ for (const h of hunks) {
727
+ const aCount = h.lines.filter((o) => o.tag !== "+").length;
728
+ const bCount = h.lines.filter((o) => o.tag !== "-").length;
729
+ out.push(c.cyan(`@@ -${h.aStart + 1},${aCount} +${h.bStart + 1},${bCount} @@`));
730
+ for (const o of h.lines) {
731
+ if (o.tag === "+")
732
+ out.push(c.green("+" + o.line));
733
+ else if (o.tag === "-")
734
+ out.push(c.red("-" + o.line));
735
+ else
736
+ out.push(c.dim(" " + o.line));
737
+ }
738
+ }
739
+ return out.join(`
740
+ `);
741
+ };
742
+
743
+ // src/commands/diff.ts
744
+ var diff = async (args) => {
745
+ const root = findProjectRoot(process.cwd());
746
+ const manifest = readManifest(root);
747
+ const config = loadConfig();
748
+ const apiUrl = requireApiUrl(config);
749
+ const token = requireToken(config);
750
+ info(c.dim("Comparing with the server…"));
751
+ const project = await getProject(apiUrl, token, manifest.uuid);
752
+ const scan = scanProject(root, manifest);
753
+ let entries = computeServerDiff(project, scan);
754
+ if (args._.length > 0) {
755
+ const keys = args._.map(toKey);
756
+ entries = entries.filter((e) => keys.some((k) => e.path === k || e.path.startsWith(k + "/")));
757
+ }
758
+ if (entries.length === 0) {
759
+ info(c.dim("No differences between local and server."));
760
+ return;
761
+ }
762
+ if (flagBool(args, "name-only")) {
763
+ for (const e of entries)
764
+ log(statusLabel(e) + e.path + (e.binary ? c.dim(" (binary)") : ""));
765
+ return;
766
+ }
767
+ for (const e of entries) {
768
+ if (e.binary) {
769
+ log(statusLabel(e) + e.path + c.dim(" (binary — content not shown)"));
770
+ continue;
771
+ }
772
+ const d = unifiedDiff(e.serverText ?? "", e.localText ?? "", `server${e.path}`, `local${e.path}`);
773
+ if (d)
774
+ log(`
775
+ ` + d);
776
+ }
777
+ };
778
+ var statusLabel = (e) => e.status === "added" ? c.green(" added ") : e.status === "removed" ? c.red(" removed ") : c.yellow(" modified ");
779
+
368
780
  // src/browserAuth.ts
369
781
  import { createServer } from "node:http";
370
782
  import { spawn } from "node:child_process";
@@ -401,10 +813,10 @@ var openBrowser = (url) => {
401
813
  };
402
814
  var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser) => {
403
815
  const state = randomBytes(16).toString("hex");
404
- let resolve2;
816
+ let resolve3;
405
817
  let reject;
406
818
  const tokenPromise = new Promise((res, rej) => {
407
- resolve2 = res;
819
+ resolve3 = res;
408
820
  reject = rej;
409
821
  });
410
822
  const server = createServer((req, res) => {
@@ -422,7 +834,7 @@ var browserLogin = async (webBase, timeoutMs = 5 * 60 * 1000, open = openBrowser
422
834
  }
423
835
  res.writeHead(200, { "content-type": "text/html" });
424
836
  res.end(SUCCESS_HTML);
425
- resolve2(token);
837
+ resolve3(token);
426
838
  });
427
839
  await new Promise((res, rej) => {
428
840
  server.once("error", rej);
@@ -455,101 +867,12 @@ var login = async (args) => {
455
867
  };
456
868
 
457
869
  // src/commands/preview.ts
458
- import { writeFileSync as writeFileSync5 } from "node:fs";
459
- import { resolve as resolve2 } from "node:path";
460
-
461
- // 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;
870
+ import { writeFileSync as writeFileSync7 } from "node:fs";
871
+ import { resolve as resolve3 } from "node:path";
551
872
 
552
873
  // src/compile/collect.ts
874
+ import { readFileSync as readFileSync6 } from "node:fs";
875
+ import { basename as basename2 } from "node:path";
553
876
  var collectEntries = (root, manifest, project) => {
554
877
  const pathById = buildPaths(project.assets);
555
878
  const serverByPath = new Map;
@@ -561,7 +884,7 @@ var collectEntries = (root, manifest, project) => {
561
884
  const entries = [];
562
885
  const warnings = [];
563
886
  for (const file of scanLocal(root).files) {
564
- const type = manifest.files[file.path]?.type ?? classifyKind(basename(file.path));
887
+ const type = manifest.files[file.path]?.type ?? classifyKind(basename2(file.path));
565
888
  if (type === "resource") {
566
889
  const src = serverByPath.get(file.path)?.fileSrc;
567
890
  if (!src)
@@ -573,19 +896,23 @@ var collectEntries = (root, manifest, project) => {
573
896
  warnings.push(`No compiled shader for ${file.path} — push it to compile the shader.`);
574
897
  entries.push({ path: file.path, type, shaders });
575
898
  } else {
576
- entries.push({ path: file.path, type, text: readFileSync4(file.absPath, "utf8") });
899
+ entries.push({ path: file.path, type, text: readFileSync6(file.absPath, "utf8") });
577
900
  }
578
901
  }
579
902
  return { entries, warnings };
580
903
  };
581
904
 
582
- // src/compile/bundler.ts
905
+ // src/compile/projectCompile.ts
906
+ import { existsSync as existsSync6 } from "node:fs";
907
+ import { dirname as dirname5, join as join9 } from "node:path";
908
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
909
+ // ../sdk/src/compile/bundler.ts
583
910
  import * as esbuild from "esbuild";
584
- import { existsSync as existsSync3 } from "node:fs";
585
- import { dirname as dirname3, join as join6 } from "node:path";
911
+ import { existsSync as existsSync5 } from "node:fs";
912
+ import { dirname as dirname4, join as join8 } from "node:path";
586
913
  import { fileURLToPath } from "node:url";
587
914
 
588
- // src/compile/assetMacro.ts
915
+ // ../sdk/src/compile/assetMacro.ts
589
916
  var ASSET_RE = /\basset\(\s*(["'])((?:[^"'\\]|\\.)*)\1\s*\)/g;
590
917
  var hasAssetMacro = (src) => src.includes("asset(");
591
918
  var desugarAssetMacros = (src, filePath) => {
@@ -610,52 +937,21 @@ var desugarAssetMacros = (src, filePath) => {
610
937
  ` + out;
611
938
  };
612
939
 
613
- // src/compile/bundler.ts
614
- var GL_MATRIX_HEADER = `
615
- import * as glMatrix from "gl-matrix/common";
616
- import * as mat2 from "gl-matrix/mat2";
617
- import * as mat2d from "gl-matrix/mat2d";
618
- import * as mat3 from "gl-matrix/mat3";
619
- import * as mat4 from "gl-matrix/mat4";
620
- import * as quat from "gl-matrix/quat";
621
- import * as quat2 from "gl-matrix/quat2";
622
- import * as vec2 from "gl-matrix/vec2";
623
- import * as vec3 from "gl-matrix/vec3";
624
- import * as vec4 from "gl-matrix/vec4";
625
- `;
626
- var resolveRuntime = () => {
627
- 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");
631
- return {
632
- 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")
638
- },
639
- inject: [join6(w, "globals.js")]
640
- };
641
- }
642
- const repoRoot = join6(here, "..", "..", "..", "..");
643
- const ws = join6(repoRoot, "packages", "worker", "src");
644
- if (existsSync3(join6(ws, "index.ts"))) {
645
- return {
646
- 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")
652
- },
653
- inject: [join6(ws, "globals.ts")]
654
- };
655
- }
656
- throw new CliError("Could not locate the compile runtime — try reinstalling lecodes-cli.");
657
- };
658
- function normalize(path) {
940
+ // ../sdk/src/compile/bundler.ts
941
+ var resolveInject = () => {
942
+ const override = process.env.LECODES_SDK_INJECT;
943
+ if (override && existsSync5(override))
944
+ return override;
945
+ const here = dirname4(fileURLToPath(import.meta.url));
946
+ const src = join8(here, "..", "inject.ts");
947
+ if (existsSync5(src))
948
+ return src;
949
+ const dist = join8(here, "..", "..", "dist", "inject.js");
950
+ if (existsSync5(dist))
951
+ return dist;
952
+ throw new Error("sdk: could not locate the inject entry (LECODES_SDK_INJECT / src/inject.ts / dist/inject.js)");
953
+ };
954
+ var normalize = (path) => {
659
955
  const parts = [];
660
956
  for (const part of path.split("/")) {
661
957
  if (!part || part === ".")
@@ -666,20 +962,20 @@ function normalize(path) {
666
962
  parts.push(part);
667
963
  }
668
964
  return "/" + parts.join("/");
669
- }
670
- function dirOf(path) {
965
+ };
966
+ var dirOf = (path) => {
671
967
  const i = path.lastIndexOf("/");
672
968
  return i <= 0 ? "/" : path.slice(0, i);
673
- }
969
+ };
674
970
  var hasExtension = (path) => /\.[a-zA-Z0-9]+$/.test(path);
675
- function resolveBase(importer, specifier) {
971
+ var resolveBase = (importer, specifier) => {
676
972
  if (specifier.startsWith("/"))
677
973
  return normalize(specifier);
678
974
  if (specifier.startsWith("./") || specifier.startsWith("../"))
679
975
  return normalize(dirOf(importer) + "/" + specifier);
680
- throw new Error(`Bare import is not allowed here: ${specifier}`);
681
- }
682
- function resolveWithExtensions(files, importer, specifier) {
976
+ throw new Error(`Bare imports are not allowed — the whole SDK is available as globals (no imports): ${specifier}`);
977
+ };
978
+ var resolveWithExtensions = (files, importer, specifier) => {
683
979
  const base = resolveBase(importer, specifier);
684
980
  if (hasExtension(base)) {
685
981
  if (files.has(base))
@@ -691,31 +987,26 @@ function resolveWithExtensions(files, importer, specifier) {
691
987
  return candidate;
692
988
  }
693
989
  throw new Error(`Module not found: ${specifier} from ${importer}`);
694
- }
990
+ };
695
991
  var getLoader = (path) => path.endsWith(".ts") ? "ts" : "js";
696
- async function buildInMemory(entrypoint, inputFiles, format = "esm") {
697
- const runtime = resolveRuntime();
698
- const files = new Map(Object.entries(inputFiles).map(([k, v]) => [normalize(k), v]));
992
+ var bundleProject = async (entrypoint, files, format = "esm") => {
993
+ const fileMap = new Map(Object.entries(files).map(([k, v]) => [normalize(k), v]));
699
994
  const entry = normalize(entrypoint);
700
- if (!files.has(entry))
701
- throw new CliError(`Entrypoint not found: ${entry}`);
995
+ if (!fileMap.has(entry))
996
+ throw new Error(`Entrypoint not found: ${entry}`);
702
997
  const plugin = {
703
998
  name: "memfs",
704
999
  setup(build2) {
705
1000
  build2.onResolve({ filter: /^entry$/ }, () => ({ path: entry, namespace: "mem" }));
706
1001
  build2.onResolve({ filter: /.*/, namespace: "mem" }, (args) => {
707
- const isRelative = args.path.startsWith("./") || args.path.startsWith("../");
708
- const isAbsolute = args.path.startsWith("/");
709
- if (!isRelative && !isAbsolute)
710
- return null;
711
1002
  try {
712
- return { path: resolveWithExtensions(files, args.importer || entry, args.path), namespace: "mem" };
1003
+ return { path: resolveWithExtensions(fileMap, args.importer || entry, args.path), namespace: "mem" };
713
1004
  } catch (error) {
714
1005
  return { errors: [{ text: error instanceof Error ? error.message : String(error) }] };
715
1006
  }
716
1007
  });
717
1008
  build2.onLoad({ filter: /.*/, namespace: "mem" }, (args) => {
718
- const contents = files.get(args.path);
1009
+ const contents = fileMap.get(args.path);
719
1010
  if (contents == null)
720
1011
  return { errors: [{ text: `Module not found: ${args.path}` }] };
721
1012
  const finalContents = /\.[tj]sx?$/.test(args.path) && hasAssetMacro(contents) ? desugarAssetMacros(contents, args.path) : contents;
@@ -725,9 +1016,8 @@ async function buildInMemory(entrypoint, inputFiles, format = "esm") {
725
1016
  };
726
1017
  const result = await esbuild.build({
727
1018
  stdin: { contents: `import "entry";`, loader: "js", sourcefile: "bootstrap.js" },
728
- alias: runtime.alias,
1019
+ inject: [resolveInject()],
729
1020
  define: { RAD2DEG: "57.29577951308232", DEG2RAD: "0.017453292519943295" },
730
- inject: runtime.inject,
731
1021
  bundle: true,
732
1022
  write: false,
733
1023
  format,
@@ -737,13 +1027,16 @@ async function buildInMemory(entrypoint, inputFiles, format = "esm") {
737
1027
  });
738
1028
  const output = result.outputFiles?.[0];
739
1029
  if (!output)
740
- throw new CliError("Compilation produced no output");
1030
+ throw new Error("Compilation produced no output");
741
1031
  return output.text;
742
- }
1032
+ };
743
1033
 
744
- // src/compile/projectCompile.ts
745
- var buildHeader = (name, code) => {
1034
+ // ../sdk/src/compile/header.ts
1035
+ var buildHeader = (name, code, opts = {}) => {
746
1036
  let header = `// name: ${name.slice(0, 20)}
1037
+ `;
1038
+ if (opts.icon)
1039
+ header += `// icon: ${opts.icon}
747
1040
  `;
748
1041
  header += `// last-updated: ${new Date().toISOString()}
749
1042
  `;
@@ -763,6 +1056,9 @@ var buildHeader = (name, code) => {
763
1056
  `;
764
1057
  else if (code.includes("_creator.createEngine"))
765
1058
  header += `// gl: defer
1059
+ `;
1060
+ if (code.includes("_creator2d."))
1061
+ header += `// 2d: enable
766
1062
  `;
767
1063
  if (code.includes("_creator.createARController"))
768
1064
  header += "// ar: enable";
@@ -770,6 +1066,8 @@ var buildHeader = (name, code) => {
770
1066
  `;
771
1067
  return header;
772
1068
  };
1069
+
1070
+ // ../sdk/src/compile/compileProject.ts
773
1071
  var detectEntry = (entries) => {
774
1072
  let entry = "";
775
1073
  let max = -1;
@@ -787,7 +1085,7 @@ var detectEntry = (entries) => {
787
1085
  var compileProject = async (opts) => {
788
1086
  const entry = opts.entryOverride ?? detectEntry(opts.entries);
789
1087
  if (!entry) {
790
- throw new CliError("Couldn't find an entrypoint (a top-level .ts/.js file with no `export`). Pass --entry <path>.");
1088
+ throw new Error("Couldn't find an entrypoint (a top-level .ts/.js file with no `export`). Pass entryOverride.");
791
1089
  }
792
1090
  const files = {};
793
1091
  for (const e of opts.entries) {
@@ -805,12 +1103,24 @@ var compileProject = async (opts) => {
805
1103
  } else if (e.path.toLowerCase().endsWith(".svg")) {
806
1104
  files[e.path] = `export default SvgSource(\`${e.text ?? ""}\`)`;
807
1105
  } else {
808
- files[e.path] = GL_MATRIX_HEADER + (e.text ?? "");
1106
+ files[e.path] = e.text ?? "";
809
1107
  }
810
1108
  }
811
- const code = await buildInMemory(entry, files, opts.format ?? "esm");
812
- return opts.header === false ? code : buildHeader(opts.name, code) + code;
1109
+ const code = await bundleProject(entry, files, opts.format ?? "esm");
1110
+ return opts.header === false ? code : buildHeader(opts.name, code, { icon: opts.icon }) + code;
813
1111
  };
1112
+ // src/compile/projectCompile.ts
1113
+ var vendoredInject = join9(dirname5(fileURLToPath2(import.meta.url)), "..", "runtime", "inject.js");
1114
+ if (existsSync6(vendoredInject))
1115
+ process.env.LECODES_SDK_INJECT = vendoredInject;
1116
+ var compileProject2 = (opts) => compileProject({
1117
+ entries: opts.entries.map((e) => ({ path: e.path, type: e.type, text: e.text, fileSrc: e.fileSrc, shaders: e.shaders })),
1118
+ name: opts.name,
1119
+ publicUrl: opts.publicUrl,
1120
+ entryOverride: opts.entryOverride,
1121
+ header: opts.header,
1122
+ format: opts.format
1123
+ });
814
1124
 
815
1125
  // src/commands/preview.ts
816
1126
  var preview = async (args) => {
@@ -823,7 +1133,7 @@ var preview = async (args) => {
823
1133
  const project = await getProject(apiUrl, token, manifest.uuid);
824
1134
  const { entries, warnings } = collectEntries(root, manifest, project);
825
1135
  note("Compiling…");
826
- const js = await compileProject({
1136
+ const js = await compileProject2({
827
1137
  entries,
828
1138
  name: manifest.name,
829
1139
  publicUrl: flagStr(args, "public-url") ?? apiUrl,
@@ -833,8 +1143,10 @@ var preview = async (args) => {
833
1143
  for (const w of warnings)
834
1144
  warnErr(w);
835
1145
  const out = flagStr(args, "o", "out");
836
- if (out) {
837
- writeFileSync5(resolve2(out), js);
1146
+ if (out && isNullSink(out)) {
1147
+ note(`Discarded output (${out})`);
1148
+ } else if (out) {
1149
+ writeFileSync7(resolve3(out), js);
838
1150
  note(`Wrote ${out} (${js.length} bytes)`);
839
1151
  } else {
840
1152
  process.stdout.write(js);
@@ -846,9 +1158,37 @@ var preview = async (args) => {
846
1158
  };
847
1159
 
848
1160
  // src/commands/render.ts
849
- import { writeFileSync as writeFileSync6 } from "node:fs";
850
- import { resolve as resolve3 } from "node:path";
1161
+ import { writeFileSync as writeFileSync8 } from "node:fs";
1162
+ import { resolve as resolve4 } from "node:path";
1163
+ var DEVICE_SAFE_AREA = {
1164
+ none: [0, 0, 0, 0],
1165
+ iphone: [47, 0, 34, 0],
1166
+ android: [24, 0, 24, 0]
1167
+ };
1168
+ var resolveSafeArea = (args) => {
1169
+ const explicit = flagStr(args, "safe-area");
1170
+ if (explicit) {
1171
+ const n = explicit.split(",").map((s) => Number(s.trim()));
1172
+ if (n.length === 4 && n.every((v) => Number.isFinite(v)))
1173
+ return n;
1174
+ throw new CliError(`--safe-area expects "top,right,bottom,left" (4 numbers), got "${explicit}".`);
1175
+ }
1176
+ const device = flagStr(args, "device");
1177
+ if (device) {
1178
+ const preset = DEVICE_SAFE_AREA[device];
1179
+ if (!preset)
1180
+ throw new CliError(`Unknown --device "${device}". Options: ${Object.keys(DEVICE_SAFE_AREA).join(", ")}.`);
1181
+ return preset;
1182
+ }
1183
+ return;
1184
+ };
851
1185
  var render = async (args) => {
1186
+ const timeoutMs = Number(flagStr(args, "timeout")) || 15000;
1187
+ const killer = setTimeout(() => {
1188
+ warnErr(`Render timed out after ${timeoutMs}ms (try --settle / --wait-network, or raise --timeout).`);
1189
+ process.exit(1);
1190
+ }, timeoutMs);
1191
+ killer.unref?.();
852
1192
  const root = findProjectRoot(process.cwd());
853
1193
  const manifest = readManifest(root);
854
1194
  const config = loadConfig();
@@ -858,7 +1198,7 @@ var render = async (args) => {
858
1198
  const project = await getProject(apiUrl, token, manifest.uuid);
859
1199
  const { entries, warnings } = collectEntries(root, manifest, project);
860
1200
  note("Compiling…");
861
- const js = await compileProject({
1201
+ const js = await compileProject2({
862
1202
  entries,
863
1203
  name: manifest.name,
864
1204
  publicUrl: flagStr(args, "public-url") ?? apiUrl,
@@ -877,48 +1217,75 @@ var render = async (args) => {
877
1217
  }
878
1218
  const width = Number(flagStr(args, "width")) || 390;
879
1219
  const height = Number(flagStr(args, "height")) || 844;
1220
+ const settleStr = flagStr(args, "settle");
1221
+ const timeStr = flagStr(args, "time");
1222
+ const onConsole = flagBool(args, "logs") ? (level, text2) => logErr(`${c.dim(`[${level}]`)} ${text2}`) : undefined;
1223
+ const timing = {
1224
+ settleMs: settleStr !== undefined ? Number(settleStr) : undefined,
1225
+ waitNetwork: flagBool(args, "wait-network"),
1226
+ timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
1227
+ afterAnimations: !flagBool(args, "no-after-animations"),
1228
+ safeArea: resolveSafeArea(args),
1229
+ onConsole
1230
+ };
1231
+ const done = (flush) => flush(() => {
1232
+ clearTimeout(killer);
1233
+ process.exit(0);
1234
+ });
880
1235
  const pngFlag = flagStr(args, "png") ?? (flagBool(args, "png") ? "screenshot.png" : undefined);
881
1236
  if (pngFlag !== undefined) {
882
1237
  note("Rendering PNG…");
883
1238
  const { png, warnings: pngWarnings } = await renderer.renderToPng(js, {
884
1239
  width,
885
1240
  height,
886
- scale: Number(flagStr(args, "scale")) || 2
1241
+ scale: Number(flagStr(args, "scale")) || 2,
1242
+ ...timing
887
1243
  });
888
- const pngPath = resolve3(pngFlag);
889
- writeFileSync6(pngPath, png);
890
1244
  for (const w of [...warnings, ...pngWarnings])
891
1245
  warnErr(w);
892
- note(`Wrote ${pngFlag} (${png.length} bytes, ${width}x${height})`);
893
- return;
1246
+ if (isNullSink(pngFlag)) {
1247
+ note(`Discarded PNG (${pngFlag})`);
1248
+ } else {
1249
+ writeFileSync8(resolve4(pngFlag), png);
1250
+ note(`Wrote ${pngFlag} (${png.length} bytes, ${width}x${height})`);
1251
+ }
1252
+ return done((cb) => cb());
894
1253
  }
895
1254
  note("Rendering…");
896
- const result = await renderer.renderToJson(js, { width, height });
1255
+ const result = await renderer.renderToJson(js, { width, height, ...timing });
897
1256
  for (const w of [...warnings, ...result.warnings])
898
1257
  warnErr(w);
899
1258
  const out = flagStr(args, "o", "out");
1259
+ if (out && isNullSink(out)) {
1260
+ note(`Discarded output (${out})`);
1261
+ return done((cb) => cb());
1262
+ }
900
1263
  if (out) {
901
- writeFileSync6(resolve3(out), result.text);
1264
+ writeFileSync8(resolve4(out), result.text);
902
1265
  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
- `);
1266
+ return done((cb) => cb());
909
1267
  }
1268
+ const text = result.text.endsWith(`
1269
+ `) ? result.text : result.text + `
1270
+ `;
1271
+ return done((cb) => process.stdout.write(text, cb));
910
1272
  };
911
1273
 
912
1274
  // src/commands/pull.ts
913
- import { existsSync as existsSync4, rmSync } from "node:fs";
914
- import { join as join7 } from "node:path";
1275
+ import { existsSync as existsSync7, mkdirSync as mkdirSync7, rmSync, writeFileSync as writeFileSync9 } from "node:fs";
1276
+ import { dirname as dirname6, join as join10 } from "node:path";
915
1277
  var pull = async (args) => {
916
1278
  const root = findProjectRoot(process.cwd());
917
1279
  const manifest = readManifest(root);
918
1280
  const config = loadConfig();
919
1281
  const apiUrl = requireApiUrl(config);
920
1282
  const token = requireToken(config);
921
- if (!flagBool(args, "force") && hasChanges(diffLocal(manifest, scanLocal(root)))) {
1283
+ if (args._.length > 0) {
1284
+ const project2 = await getProject(apiUrl, token, manifest.uuid);
1285
+ await pullPaths(root, manifest, project2, args._.map(toKey), apiUrl, token);
1286
+ return;
1287
+ }
1288
+ if (!flagBool(args, "force") && hasChanges(diffLocal(manifest, scanProject(root, manifest)))) {
922
1289
  throw new CliError("You have local changes. Push them first, or run `lecodes pull --force` to discard them.");
923
1290
  }
924
1291
  info(c.dim("Fetching project…"));
@@ -926,8 +1293,8 @@ var pull = async (args) => {
926
1293
  const remotePaths = new Set([...buildPaths(project.assets).entries()].filter(([id]) => project.assets.find((a) => a.id === id)?.type !== "folder").map(([, path]) => path));
927
1294
  for (const path of Object.keys(manifest.files)) {
928
1295
  if (!remotePaths.has(path)) {
929
- const abs = join7(root, ...path.replace(/^\//, "").split("/"));
930
- if (existsSync4(abs))
1296
+ const abs = join10(root, ...path.replace(/^\//, "").split("/"));
1297
+ if (existsSync7(abs))
931
1298
  rmSync(abs);
932
1299
  }
933
1300
  }
@@ -941,42 +1308,110 @@ var pull = async (args) => {
941
1308
  writeManifest(root, { ...manifest, name: project.name, baseHeadId: commits.headId, files });
942
1309
  success(`Pulled latest into ${c.bold(manifest.name)}.`);
943
1310
  };
1311
+ var kindOf2 = (type) => type === "file" ? "resource" : type;
1312
+ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
1313
+ const pathById = buildPaths(project.assets);
1314
+ const byPath = new Map;
1315
+ for (const asset of project.assets)
1316
+ byPath.set(pathById.get(asset.id), asset);
1317
+ const targets = [];
1318
+ for (const path of paths) {
1319
+ const asset = byPath.get(path);
1320
+ if (!asset)
1321
+ throw new CliError(`Not found on the server: ${path}`);
1322
+ if (asset.type === "folder") {
1323
+ const prefix = path + "/";
1324
+ const under = project.assets.filter((a) => a.type !== "folder" && pathById.get(a.id).startsWith(prefix));
1325
+ if (under.length === 0)
1326
+ throw new CliError(`Folder is empty on the server: ${path}`);
1327
+ targets.push(...under);
1328
+ } else {
1329
+ targets.push(asset);
1330
+ }
1331
+ }
1332
+ const files = { ...manifest.files };
1333
+ for (const asset of targets) {
1334
+ const key = pathById.get(asset.id);
1335
+ const abs = join10(root, ...key.replace(/^\//, "").split("/"));
1336
+ mkdirSync7(dirname6(abs), { recursive: true });
1337
+ let sha;
1338
+ if (asset.type === "file") {
1339
+ if (!asset.file?.src)
1340
+ continue;
1341
+ const bytes = await downloadBlob(apiUrl, asset.file.src);
1342
+ writeFileSync9(abs, bytes);
1343
+ sha = sha256(bytes);
1344
+ } else {
1345
+ const text = asset.text ?? "";
1346
+ writeFileSync9(abs, text);
1347
+ sha = sha256(text);
1348
+ }
1349
+ files[key] = { id: asset.id, type: kindOf2(asset.type), sha };
1350
+ info(c.green(` pulled ${key}`));
1351
+ }
1352
+ writeManifest(root, { ...manifest, files });
1353
+ success(`Pulled ${targets.length} file${targets.length === 1 ? "" : "s"} from the server.`);
1354
+ info(c.dim("Local edits to other files are untouched. Run `lecodes diff` to review, then `lecodes push --force`."));
1355
+ };
944
1356
 
945
1357
  // src/commands/push.ts
946
- import { basename as basename2 } from "node:path";
947
- import { readFileSync as readFileSync5 } from "node:fs";
1358
+ import { basename as basename3 } from "node:path";
1359
+ import { readFileSync as readFileSync7 } from "node:fs";
948
1360
  var push = async (args) => {
949
1361
  const root = findProjectRoot(process.cwd());
950
1362
  const manifest = readManifest(root);
951
1363
  const config = loadConfig();
952
1364
  const apiUrl = requireApiUrl(config);
953
1365
  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."));
1366
+ const publish = flagBool(args, "publish");
1367
+ const scan = scanProject(root, manifest);
1368
+ const diff2 = diffLocal(manifest, scan);
1369
+ if (!hasChanges(diff2)) {
1370
+ if (publish) {
1371
+ await publishProject(apiUrl, token, manifest.uuid);
1372
+ success("No file changes — published the current project state.");
1373
+ } else {
1374
+ info(c.dim("Working tree clean — nothing to push."));
1375
+ }
958
1376
  return;
959
1377
  }
960
- if (!flagBool(args, "force") && manifest.baseHeadId !== null) {
1378
+ const force = flagBool(args, "force");
1379
+ if (!force && manifest.baseHeadId !== null) {
961
1380
  const commits = await getCommits(apiUrl, token, manifest.uuid);
962
1381
  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.");
1382
+ 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.");
1383
+ }
1384
+ }
1385
+ if (force) {
1386
+ const project = await getProject(apiUrl, token, manifest.uuid);
1387
+ const serverDiff = computeServerDiff(project, scan);
1388
+ if (serverDiff.length) {
1389
+ info(c.yellow(`--force will overwrite the server. ${serverDiff.length} file${serverDiff.length === 1 ? "" : "s"} change:`));
1390
+ const label = { added: c.green(" create "), removed: c.red(" delete "), modified: c.yellow(" update ") };
1391
+ for (const e of serverDiff)
1392
+ log(label[e.status] + e.path + (e.binary ? c.dim(" (binary)") : ""));
1393
+ if (process.stdin.isTTY && !flagBool(args, "y", "yes")) {
1394
+ if (!await confirm("Overwrite the server with your local copy?", false)) {
1395
+ info(c.dim("Aborted."));
1396
+ return;
1397
+ }
1398
+ }
964
1399
  }
965
1400
  }
966
1401
  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);
1402
+ const sourceFor = (path) => manifest.files[path] ?? (diff2.moves.has(path) ? manifest.files[diff2.moves.get(path)] : undefined);
968
1403
  const files = [];
969
1404
  let uploaded = 0;
970
1405
  for (const file of scan.files) {
971
1406
  const source = sourceFor(file.path);
972
- const kind = source?.type ?? classifyKind(basename2(file.path));
1407
+ const kind = source?.type ?? classifyKind(basename3(file.path));
973
1408
  const entry = { path: file.path, type: kind };
974
1409
  if (source)
975
1410
  entry.assetId = source.id;
976
1411
  if (isTextKind(kind)) {
977
- entry.text = readFileSync5(file.absPath, "utf8");
1412
+ entry.text = readFileSync7(file.absPath, "utf8");
978
1413
  } else if (!source || source.sha !== file.sha) {
979
- const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename2(file.path), readFileSync5(file.absPath));
1414
+ const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename3(file.path), readFileSync7(file.absPath));
980
1415
  entry.blobFileId = blob.id;
981
1416
  uploaded++;
982
1417
  }
@@ -985,9 +1420,11 @@ var push = async (args) => {
985
1420
  const folders = scan.dirs.map((path) => ({ path }));
986
1421
  if (uploaded > 0)
987
1422
  info(c.dim(`Uploaded ${uploaded} binary file${uploaded === 1 ? "" : "s"}.`));
988
- const res = await sync(apiUrl, token, manifest.uuid, { message, files, folders });
1423
+ const res = await sync(apiUrl, token, manifest.uuid, { message, files, folders, publish });
989
1424
  if (!res.created) {
990
1425
  info(c.dim("Server reported no changes."));
1426
+ if (publish)
1427
+ reportPublish(res);
991
1428
  return;
992
1429
  }
993
1430
  const shaByPath = new Map(scan.files.map((f) => [f.path, f.sha]));
@@ -997,37 +1434,49 @@ var push = async (args) => {
997
1434
  }
998
1435
  writeManifest(root, { ...manifest, baseHeadId: res.headId, files: newFiles });
999
1436
  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`
1437
+ diff2.added.length - diff2.moves.size && `${diff2.added.length - diff2.moves.size} added`,
1438
+ diff2.modified.length && `${diff2.modified.length} modified`,
1439
+ diff2.moves.size && `${diff2.moves.size} moved`,
1440
+ diff2.deleted.length - diff2.moves.size && `${diff2.deleted.length - diff2.moves.size} deleted`
1004
1441
  ].filter(Boolean).join(", ");
1005
1442
  success(`Pushed checkpoint${res.seq ? ` #${res.seq}` : ""}${counts ? ` (${counts})` : ""}: ${message}`);
1443
+ if (publish)
1444
+ reportPublish(res);
1445
+ };
1446
+ var reportPublish = (res) => {
1447
+ if (res.published)
1448
+ success("Published — the project is now live.");
1449
+ else
1450
+ warn(`Push landed, but publish failed: ${res.publishError ?? "unknown error"}`);
1006
1451
  };
1007
1452
 
1008
1453
  // src/commands/status.ts
1009
1454
  var status = async (_args) => {
1010
1455
  const root = findProjectRoot(process.cwd());
1011
1456
  const manifest = readManifest(root);
1012
- const diff = diffLocal(manifest, scanLocal(root));
1457
+ const scan = scanProject(root, manifest);
1458
+ const diff2 = diffLocal(manifest, scan);
1013
1459
  info(`Project ${c.bold(manifest.name)} (${manifest.uuid})`);
1014
- if (!hasChanges(diff)) {
1015
- info(c.dim("Working tree clean — nothing to push."));
1460
+ const ignoredNote = scan.ignored.length ? c.dim(` (${scan.ignored.length} ignored)`) : "";
1461
+ if (!hasChanges(diff2)) {
1462
+ info(c.dim("Working tree clean — nothing to push.") + ignoredNote);
1016
1463
  return;
1017
1464
  }
1018
- for (const f of diff.added) {
1019
- if (!diff.moves.has(f.path))
1465
+ for (const f of diff2.added) {
1466
+ if (!diff2.moves.has(f.path))
1020
1467
  log(c.green(` added ${f.path}`));
1021
1468
  }
1022
- for (const f of diff.modified)
1469
+ for (const f of diff2.modified)
1023
1470
  log(c.yellow(` modified ${f.path}`));
1024
- for (const [to, from] of diff.moves)
1471
+ for (const [to, from] of diff2.moves)
1025
1472
  log(c.cyan(` moved ${from} → ${to}`));
1026
- const movedFrom = new Set(diff.moves.values());
1027
- for (const path of diff.deleted) {
1473
+ const movedFrom = new Set(diff2.moves.values());
1474
+ for (const path of diff2.deleted) {
1028
1475
  if (!movedFrom.has(path))
1029
1476
  log(c.red(` deleted ${path}`));
1030
1477
  }
1478
+ if (scan.ignored.length)
1479
+ info(c.dim(` ${scan.ignored.length} ignored (.lecodesignore)`));
1031
1480
  };
1032
1481
 
1033
1482
  // src/index.ts
@@ -1040,14 +1489,23 @@ ${c.bold("Commands:")}
1040
1489
  --token <pat> store a token from the web UI instead
1041
1490
  --api <url> API origin (default https://le.codes)
1042
1491
  --web <url> app origin for the browser flow
1492
+ create <name> [dir] Create a new project on the server and scaffold it locally
1493
+ (types from the server + tsconfig + a starter main.ts)
1494
+ --team <uuid> create it inside a team
1043
1495
  clone <uuid|url> [dir] Download a project's files + types into a new folder
1044
1496
  --no-types skip the .d.ts / tsconfig generation
1045
1497
  status Show local changes vs the last sync
1046
- pull Overwrite local files with the project's current state
1498
+ diff [path…] Show a unified diff of local vs the current server state
1499
+ --name-only list changed paths + status only
1500
+ pull [path…] Overwrite local files with the project's current state
1501
+ (with path… : pull only those files/folders, keep other edits)
1047
1502
  --force discard uncommitted local changes
1048
1503
  push Push local changes as one new checkpoint
1049
1504
  -m, --message checkpoint message
1050
- --force push even if the server moved on
1505
+ --publish also deploy the project live (compile) with the push
1506
+ --force push even if the server moved on (shows + confirms
1507
+ which server files get overwritten)
1508
+ -y, --yes skip the --force overwrite confirmation
1051
1509
  preview Compile the local project to JS (prints to stdout)
1052
1510
  -o, --out <file> write to a file instead of stdout
1053
1511
  --entry <path> override the entrypoint
@@ -1061,9 +1519,20 @@ ${c.bold("Commands:")}
1061
1519
  --scale <n> PNG device-pixel scale (default 2)
1062
1520
  --width/--height viewport size (default 390x844)
1063
1521
  --entry <path> override the entrypoint
1522
+ --settle <ms> wait for promises/timers/fetch before snapshot
1523
+ --wait-network let data fetches hit the real network
1524
+ --logs forward the app's console output (to stderr)
1064
1525
 
1065
1526
  Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKEN).`;
1066
- var commands = { login, clone, status, pull, push, preview, render };
1527
+ var commands = { login, create, clone, status, diff, pull, push, preview, render };
1528
+ var readVersion = () => {
1529
+ try {
1530
+ const here = dirname7(fileURLToPath3(import.meta.url));
1531
+ return JSON.parse(readFileSync8(join11(here, "..", "package.json"), "utf8")).version ?? "unknown";
1532
+ } catch {
1533
+ return "unknown";
1534
+ }
1535
+ };
1067
1536
  var main = async () => {
1068
1537
  const argv = process.argv.slice(2);
1069
1538
  const command = argv[0];
@@ -1071,6 +1540,10 @@ var main = async () => {
1071
1540
  log(HELP);
1072
1541
  return;
1073
1542
  }
1543
+ if (command === "--version" || command === "-v" || command === "version") {
1544
+ log(readVersion());
1545
+ return;
1546
+ }
1074
1547
  const run = commands[command];
1075
1548
  if (!run) {
1076
1549
  log(c.red(`Unknown command: ${command}`));