lecodes-cli 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +7 -1
  2. package/dist/index.js +202 -84
  3. package/package.json +4 -4
  4. package/runtime/sdk/animate/animate.ts +238 -0
  5. package/runtime/sdk/animate/bezier.ts +138 -0
  6. package/runtime/sdk/animate/easings.ts +126 -0
  7. package/runtime/sdk/core/Aspect.ts +151 -0
  8. package/runtime/sdk/core/color.ts +66 -0
  9. package/runtime/sdk/core/events.ts +38 -0
  10. package/runtime/sdk/core/registry.ts +23 -0
  11. package/runtime/sdk/g2/Camera2D.ts +33 -0
  12. package/runtime/sdk/g2/CharacterController2D.ts +75 -0
  13. package/runtime/sdk/g2/Node2D.ts +194 -0
  14. package/runtime/sdk/g2/Physics2D.ts +126 -0
  15. package/runtime/sdk/g2/Scene2D.ts +101 -0
  16. package/runtime/sdk/g2/Shape.ts +63 -0
  17. package/runtime/sdk/g2/Sprite.ts +79 -0
  18. package/runtime/sdk/g2/SpriteAnimation.ts +90 -0
  19. package/runtime/sdk/g2/Texture2D.ts +37 -0
  20. package/runtime/sdk/g2/Tilemap.ts +41 -0
  21. package/runtime/sdk/g2/Trigger.ts +47 -0
  22. package/runtime/sdk/g2/loop.ts +39 -0
  23. package/runtime/sdk/g2/touch.ts +83 -0
  24. package/runtime/sdk/gl/Camera.ts +40 -0
  25. package/runtime/sdk/gl/Collider.ts +29 -0
  26. package/runtime/sdk/gl/Geometry.ts +218 -0
  27. package/runtime/sdk/gl/Light.ts +37 -0
  28. package/runtime/sdk/gl/Material.ts +124 -0
  29. package/runtime/sdk/gl/Mesh.ts +100 -0
  30. package/runtime/sdk/gl/Model.ts +40 -0
  31. package/runtime/sdk/gl/ModelAnimation.ts +64 -0
  32. package/runtime/sdk/gl/Node.ts +234 -0
  33. package/runtime/sdk/gl/Noise.ts +30 -0
  34. package/runtime/sdk/gl/Particles.ts +185 -0
  35. package/runtime/sdk/gl/Physics.ts +95 -0
  36. package/runtime/sdk/gl/Plane.ts +53 -0
  37. package/runtime/sdk/gl/Ray.ts +16 -0
  38. package/runtime/sdk/gl/Scene.ts +179 -0
  39. package/runtime/sdk/gl/Texture.ts +35 -0
  40. package/runtime/sdk/gl/controls.ts +95 -0
  41. package/runtime/sdk/gl/state.ts +6 -0
  42. package/runtime/sdk/gl/touch.ts +56 -0
  43. package/runtime/sdk/inject.ts +85 -0
  44. package/runtime/sdk/math/Mathf.ts +118 -0
  45. package/runtime/sdk/math/mat4.ts +278 -0
  46. package/runtime/sdk/math/quat.ts +232 -0
  47. package/runtime/sdk/math/vec.ts +255 -0
  48. package/runtime/sdk/plugins/qr.ts +36 -0
  49. package/runtime/sdk/runtime/device.ts +33 -0
  50. package/runtime/sdk/runtime/fetch.ts +71 -0
  51. package/runtime/sdk/runtime/files.ts +23 -0
  52. package/runtime/sdk/runtime/input.ts +12 -0
  53. package/runtime/sdk/runtime/media.ts +111 -0
  54. package/runtime/sdk/runtime/misc.ts +12 -0
  55. package/runtime/sdk/runtime/net.ts +36 -0
  56. package/runtime/sdk/runtime/storage.ts +13 -0
  57. package/runtime/sdk/runtime/touch.ts +50 -0
  58. package/runtime/sdk/ui/UI.ts +19 -0
  59. package/runtime/sdk/ui/UIButton.ts +84 -0
  60. package/runtime/sdk/ui/UIContainer.ts +100 -0
  61. package/runtime/sdk/ui/UIImage.ts +68 -0
  62. package/runtime/sdk/ui/UIInput.ts +78 -0
  63. package/runtime/sdk/ui/UINode.ts +342 -0
  64. package/runtime/sdk/ui/UIScreen.ts +122 -0
  65. package/runtime/sdk/ui/UIScrollable.ts +70 -0
  66. package/runtime/sdk/ui/UISpacer.ts +14 -0
  67. package/runtime/sdk/ui/UIText.ts +40 -0
  68. package/runtime/sdk/ui/UIVideo.ts +38 -0
  69. package/runtime/sdk/ui/UIVirtualizedList.ts +222 -0
  70. package/runtime/sdk/ui/UIWidget.ts +97 -0
  71. package/runtime/sdk/ui/fonts.ts +10 -0
  72. package/runtime/sdk/ui/router.ts +73 -0
  73. package/runtime/inject.js +0 -4629
package/README.md CHANGED
@@ -13,6 +13,12 @@ npm i -g lecodes-cli # then run: lecodes <command>
13
13
  npx lecodes-cli <command>
14
14
  ```
15
15
 
16
+ The `render` command additionally needs the optional peer **[`lecodes-renderer`](https://www.npmjs.com/package/lecodes-renderer)** (`>= 0.2.0`, for UI + 2D rendering), plus `@napi-rs/canvas` for PNG output:
17
+
18
+ ```sh
19
+ npm i -D lecodes-renderer @napi-rs/canvas
20
+ ```
21
+
16
22
  ## Quick start
17
23
 
18
24
  ```sh
@@ -35,7 +41,7 @@ lecodes preview > app.js # compile the local project to JS
35
41
  | `pull` | Overwrite local files with the project's current remote state. `--force` to discard local edits. |
36
42
  | `push` | Push all local changes as **one** new checkpoint. `-m`/`--message` for the message, `--force` to override the "server moved on" guard. |
37
43
  | `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-renderer` package (`npm i -D lecodes-renderer`); PNG also needs `@napi-rs/canvas`. 3D scenes aren't rendered. |
44
+ | `render` | Render the local project (**UI + 2D**). 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). A creator-2d scene is composited **under** the UI; `--time <ms>` advances its physics/animation. `asset('./x.png')` resources (images, sprite textures) load from the local working tree by default — use `--remote-assets` (or an explicit `--public-url`) to fetch them from the server instead (which then needs `--wait-network`). `--width`/`--height` set the viewport (default 390×844). Requires the optional `lecodes-renderer` package (`npm i -D lecodes-renderer@^0.2.0`); PNG also needs `@napi-rs/canvas`. 3D scenes aren't rendered. |
39
45
 
40
46
  ## How it works
41
47
 
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
3
3
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
 
5
5
  // src/index.ts
6
- import { readFileSync as readFileSync8 } from "node:fs";
6
+ import { readFileSync as readFileSync9 } from "node:fs";
7
7
  import { dirname as dirname7, join as join11 } from "node:path";
8
8
  import { fileURLToPath as fileURLToPath3 } from "node:url";
9
9
 
@@ -322,10 +322,13 @@ var DEFAULT_IGNORE_FILE = `# Files matched here are NOT pushed to LeCodes.
322
322
  # Only untracked files are affected — assets already in the project stay tracked
323
323
  # even if they match a pattern. Built-in defaults (node_modules, _*, *.log) always apply.
324
324
 
325
- # Render / preview outputs
325
+ # Render / preview outputs (the default \`lecodes render --png\` target)
326
326
  screenshot.png
327
327
  shots/
328
- *.png
328
+
329
+ # Image & other binary assets ARE pushed by default — they're real project resources.
330
+ # Uncomment a pattern below only if you want to keep some untracked ones out of pushes.
331
+ # *.png
329
332
 
330
333
  # Local notes & dumps (uncomment to ignore)
331
334
  # *.json
@@ -889,7 +892,7 @@ var collectEntries = (root, manifest, project) => {
889
892
  const src = serverByPath.get(file.path)?.fileSrc;
890
893
  if (!src)
891
894
  warnings.push(`No server URL for ${file.path} — push it so its URL resolves.`);
892
- entries.push({ path: file.path, type, fileSrc: src });
895
+ entries.push({ path: file.path, type, fileSrc: src, absPath: file.absPath });
893
896
  } else if (type === "shader") {
894
897
  const shaders = serverByPath.get(file.path)?.shaders;
895
898
  if (!shaders?.length)
@@ -904,12 +907,14 @@ var collectEntries = (root, manifest, project) => {
904
907
 
905
908
  // src/compile/projectCompile.ts
906
909
  import { existsSync as existsSync6 } from "node:fs";
910
+ import { createRequire as createRequire3 } from "node:module";
907
911
  import { dirname as dirname5, join as join9 } from "node:path";
908
912
  import { fileURLToPath as fileURLToPath2 } from "node:url";
909
913
  // ../sdk/src/compile/bundler.ts
910
- import * as esbuild from "esbuild";
911
- import { existsSync as existsSync5 } from "node:fs";
912
- import { dirname as dirname4, join as join8 } from "node:path";
914
+ import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, statSync } from "node:fs";
915
+ import { spawnSync } from "node:child_process";
916
+ import { createRequire as createRequire2 } from "node:module";
917
+ import { dirname as dirname4, join as join8, relative as relative2 } from "node:path";
913
918
  import { fileURLToPath } from "node:url";
914
919
 
915
920
  // ../sdk/src/compile/assetMacro.ts
@@ -937,6 +942,36 @@ var desugarAssetMacros = (src, filePath) => {
937
942
  ` + out;
938
943
  };
939
944
 
945
+ // ../sdk/src/compile/aspectMacro.ts
946
+ var ASPECT_RE = /\bclass\s+\w+\s+extends\s+Aspect\s*<\s*(["'])((?:[^"'\\]|\\.)*)\1[^{};]*>\s*\{/g;
947
+ var hasAspectExtends = (src) => /\bextends\s+Aspect\s*</.test(src);
948
+ var injectAspectNames = (src) => {
949
+ let out = "";
950
+ let last = 0;
951
+ ASPECT_RE.lastIndex = 0;
952
+ let m;
953
+ while ((m = ASPECT_RE.exec(src)) !== null) {
954
+ const braceEnd = m.index + m[0].length;
955
+ if (!classBodyDeclaresAspect(src, braceEnd)) {
956
+ out += src.slice(last, braceEnd) + ` static aspect = ${JSON.stringify(m[2])};`;
957
+ last = braceEnd;
958
+ }
959
+ }
960
+ return last === 0 ? src : out + src.slice(last);
961
+ };
962
+ var classBodyDeclaresAspect = (src, from) => {
963
+ let depth = 1;
964
+ let i = from;
965
+ for (;i < src.length && depth > 0; i++) {
966
+ const c2 = src[i];
967
+ if (c2 === "{")
968
+ depth++;
969
+ else if (c2 === "}")
970
+ depth--;
971
+ }
972
+ return /\bstatic\b[^;{}()=]*\baspect\b/.test(src.slice(from, i));
973
+ };
974
+
940
975
  // ../sdk/src/compile/bundler.ts
941
976
  var resolveInject = () => {
942
977
  const override = process.env.LECODES_SDK_INJECT;
@@ -946,10 +981,7 @@ var resolveInject = () => {
946
981
  const src = join8(here, "..", "inject.ts");
947
982
  if (existsSync5(src))
948
983
  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)");
984
+ throw new Error("sdk: could not locate the inject source (LECODES_SDK_INJECT / src/inject.ts)");
953
985
  };
954
986
  var normalize = (path) => {
955
987
  const parts = [];
@@ -963,73 +995,96 @@ var normalize = (path) => {
963
995
  }
964
996
  return "/" + parts.join("/");
965
997
  };
966
- var dirOf = (path) => {
967
- const i = path.lastIndexOf("/");
968
- return i <= 0 ? "/" : path.slice(0, i);
969
- };
970
- var hasExtension = (path) => /\.[a-zA-Z0-9]+$/.test(path);
971
- var resolveBase = (importer, specifier) => {
972
- if (specifier.startsWith("/"))
973
- return normalize(specifier);
974
- if (specifier.startsWith("./") || specifier.startsWith("../"))
975
- return normalize(dirOf(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) => {
979
- const base = resolveBase(importer, specifier);
980
- if (hasExtension(base)) {
981
- if (files.has(base))
982
- return base;
983
- throw new Error(`Module not found: ${base}`);
984
- }
985
- for (const candidate of [base, `${base}.ts`, `${base}.js`]) {
986
- if (files.has(candidate))
987
- return candidate;
988
- }
989
- throw new Error(`Module not found: ${specifier} from ${importer}`);
990
- };
991
- var getLoader = (path) => path.endsWith(".ts") ? "ts" : "js";
992
- var bundleProject = async (entrypoint, files, format = "esm") => {
993
- const fileMap = new Map(Object.entries(files).map(([k, v]) => [normalize(k), v]));
994
- const entry = normalize(entrypoint);
995
- if (!fileMap.has(entry))
996
- throw new Error(`Entrypoint not found: ${entry}`);
997
- const plugin = {
998
- name: "memfs",
999
- setup(build2) {
1000
- build2.onResolve({ filter: /^entry$/ }, () => ({ path: entry, namespace: "mem" }));
1001
- build2.onResolve({ filter: /.*/, namespace: "mem" }, (args) => {
1002
- try {
1003
- return { path: resolveWithExtensions(fileMap, args.importer || entry, args.path), namespace: "mem" };
1004
- } catch (error) {
1005
- return { errors: [{ text: error instanceof Error ? error.message : String(error) }] };
1006
- }
1007
- });
1008
- build2.onLoad({ filter: /.*/, namespace: "mem" }, (args) => {
1009
- const contents = fileMap.get(args.path);
1010
- if (contents == null)
1011
- return { errors: [{ text: `Module not found: ${args.path}` }] };
1012
- const finalContents = /\.[tj]sx?$/.test(args.path) && hasAssetMacro(contents) ? desugarAssetMacros(contents, args.path) : contents;
1013
- return { contents: finalContents, loader: getLoader(args.path), resolveDir: dirOf(args.path) };
1014
- });
998
+ var CHISEL_DEFINE = { RAD2DEG: "57.29577951308232", DEG2RAD: "0.017453292519943295" };
999
+ var CHISEL_KEEP = ["_*"];
1000
+ var resolveSdkSrc = () => {
1001
+ const inject = resolveInject();
1002
+ if (!inject.endsWith("inject.ts")) {
1003
+ throw new Error(`chisel needs the SDK source tree (src/inject.ts); got ${inject}. Set LECODES_SDK_INJECT to the source.`);
1004
+ }
1005
+ return dirname4(inject);
1006
+ };
1007
+ var isSdkSource = (path) => /\.[tj]s$/.test(path) && !path.endsWith(".d.ts") && !path.includes("/compile/") && !path.includes("/tests/") && !path.includes("/__tests__/");
1008
+ var sdkFilesCache = new Map;
1009
+ var collectSdkFiles = (sdkSrc) => {
1010
+ const cached = sdkFilesCache.get(sdkSrc);
1011
+ if (cached)
1012
+ return cached;
1013
+ const out = {};
1014
+ const walk = (dir) => {
1015
+ for (const name of readdirSync4(dir)) {
1016
+ const p = join8(dir, name);
1017
+ if (statSync(p).isDirectory())
1018
+ walk(p);
1019
+ else if (isSdkSource(p))
1020
+ out["/__sdk/" + relative2(sdkSrc, p).split("\\").join("/")] = readFileSync7(p, "utf8");
1015
1021
  }
1016
1022
  };
1017
- const result = await esbuild.build({
1018
- stdin: { contents: `import "entry";`, loader: "js", sourcefile: "bootstrap.js" },
1019
- inject: [resolveInject()],
1020
- define: { RAD2DEG: "57.29577951308232", DEG2RAD: "0.017453292519943295" },
1021
- bundle: true,
1022
- write: false,
1023
+ walk(sdkSrc);
1024
+ sdkFilesCache.set(sdkSrc, out);
1025
+ return out;
1026
+ };
1027
+ var resolveChiselBin = () => {
1028
+ const override = process.env.LECODES_CHISEL_BIN;
1029
+ if (override) {
1030
+ if (existsSync5(override))
1031
+ return override;
1032
+ throw new Error(`LECODES_CHISEL_BIN points at a missing file: ${override}`);
1033
+ }
1034
+ try {
1035
+ const bin = createRequire2(import.meta.url)("@letary/chisel").binaryPath();
1036
+ if (bin && existsSync5(bin))
1037
+ return bin;
1038
+ } catch {}
1039
+ throw new Error("chisel binary not found. Install @letary/chisel, or set LECODES_CHISEL_BIN.");
1040
+ };
1041
+ var bundleProjectChisel = async (entrypoint, files, format, sourcemap) => {
1042
+ const entry = normalize(entrypoint);
1043
+ const projFiles = {};
1044
+ for (const [k, v] of Object.entries(files)) {
1045
+ const path = normalize(k);
1046
+ let src = v;
1047
+ if (/\.[tj]sx?$/.test(path)) {
1048
+ if (hasAssetMacro(src))
1049
+ src = desugarAssetMacros(src, path);
1050
+ if (hasAspectExtends(src))
1051
+ src = injectAspectNames(src);
1052
+ }
1053
+ projFiles[path] = src;
1054
+ }
1055
+ if (!projFiles[entry])
1056
+ throw new Error(`Entrypoint not found: ${entry}`);
1057
+ const sdkFiles = collectSdkFiles(resolveSdkSrc());
1058
+ const minify = process.env.LECODES_CHISEL_MINIFY !== "0";
1059
+ const input = JSON.stringify({
1060
+ files: { ...sdkFiles, ...projFiles },
1061
+ entry,
1062
+ inject: ["/__sdk/inject.ts"],
1023
1063
  format,
1024
- platform: "browser",
1025
- plugins: [plugin],
1026
- charset: "utf8"
1064
+ minify,
1065
+ fuse: process.env.LECODES_CHISEL_FUSE === "1",
1066
+ define: CHISEL_DEFINE,
1067
+ keep: CHISEL_KEEP,
1068
+ sourcemap
1027
1069
  });
1028
- const output = result.outputFiles?.[0];
1029
- if (!output)
1030
- throw new Error("Compilation produced no output");
1031
- return output.text;
1070
+ const bin = resolveChiselBin();
1071
+ const r = spawnSync(bin, [], { input, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
1072
+ if (r.status === 2 || r.error) {
1073
+ throw new Error(`chisel failed: ${r.error?.message ?? r.stderr ?? "unknown error"}`);
1074
+ }
1075
+ let parsed;
1076
+ try {
1077
+ parsed = JSON.parse(r.stdout || "{}");
1078
+ } catch {
1079
+ throw new Error(`chisel produced invalid output: ${(r.stdout || r.stderr || "").slice(0, 200)}`);
1080
+ }
1081
+ if (parsed.error)
1082
+ throw new Error(`chisel: ${parsed.error}`);
1083
+ if (parsed.code == null)
1084
+ throw new Error("chisel produced no code");
1085
+ return { code: parsed.code, map: parsed.map ?? null };
1032
1086
  };
1087
+ var bundleProjectWithMap = async (entrypoint, files, format = "esm") => bundleProjectChisel(entrypoint, files, format, true);
1033
1088
 
1034
1089
  // ../sdk/src/compile/header.ts
1035
1090
  var buildHeader = (name, code, opts = {}) => {
@@ -1067,6 +1122,19 @@ var buildHeader = (name, code, opts = {}) => {
1067
1122
  return header;
1068
1123
  };
1069
1124
 
1125
+ // ../sdk/src/compile/sourcemap.ts
1126
+ var offsetSourceMap = (mapJson, lines) => {
1127
+ if (lines <= 0)
1128
+ return mapJson;
1129
+ const map = JSON.parse(mapJson);
1130
+ map.mappings = ";".repeat(lines) + map.mappings;
1131
+ return JSON.stringify(map);
1132
+ };
1133
+ var inlineSourceMapComment = (mapJson) => {
1134
+ const b64 = Buffer.from(mapJson, "utf8").toString("base64");
1135
+ return `//# lecodesMap=data:application/json;charset=utf-8;base64,${b64}`;
1136
+ };
1137
+
1070
1138
  // ../sdk/src/compile/compileProject.ts
1071
1139
  var detectEntry = (entries) => {
1072
1140
  let entry = "";
@@ -1106,13 +1174,35 @@ var compileProject = async (opts) => {
1106
1174
  files[e.path] = e.text ?? "";
1107
1175
  }
1108
1176
  }
1109
- const code = await bundleProject(entry, files, opts.format ?? "esm");
1110
- return opts.header === false ? code : buildHeader(opts.name, code, { icon: opts.icon }) + code;
1177
+ const { code, map } = await bundleProjectWithMap(entry, files, opts.format ?? "esm");
1178
+ if (opts.header === false) {
1179
+ return map ? `${code}
1180
+ ${inlineSourceMapComment(map)}` : code;
1181
+ }
1182
+ const header = buildHeader(opts.name, code, { icon: opts.icon });
1183
+ if (!map)
1184
+ return header + code;
1185
+ const headerLines = (header.match(/\n/g) ?? []).length;
1186
+ return `${header}${code}
1187
+ ${inlineSourceMapComment(offsetSourceMap(map, headerLines))}`;
1111
1188
  };
1112
1189
  // src/compile/projectCompile.ts
1113
- var vendoredInject = join9(dirname5(fileURLToPath2(import.meta.url)), "..", "runtime", "inject.js");
1190
+ var here = dirname5(fileURLToPath2(import.meta.url));
1191
+ var vendoredInject = join9(here, "..", "runtime", "sdk", "inject.ts");
1114
1192
  if (existsSync6(vendoredInject))
1115
1193
  process.env.LECODES_SDK_INJECT = vendoredInject;
1194
+ var resolveChiselBin2 = () => {
1195
+ if (process.env.LECODES_CHISEL_BIN)
1196
+ return process.env.LECODES_CHISEL_BIN;
1197
+ try {
1198
+ return createRequire3(import.meta.url)("@letary/chisel").binaryPath() ?? null;
1199
+ } catch {
1200
+ return null;
1201
+ }
1202
+ };
1203
+ var chiselBin = resolveChiselBin2();
1204
+ if (chiselBin)
1205
+ process.env.LECODES_CHISEL_BIN = chiselBin;
1116
1206
  var compileProject2 = (opts) => compileProject({
1117
1207
  entries: opts.entries.map((e) => ({ path: e.path, type: e.type, text: e.text, fileSrc: e.fileSrc, shaders: e.shaders })),
1118
1208
  name: opts.name,
@@ -1160,6 +1250,7 @@ var preview = async (args) => {
1160
1250
  // src/commands/render.ts
1161
1251
  import { writeFileSync as writeFileSync8 } from "node:fs";
1162
1252
  import { resolve as resolve4 } from "node:path";
1253
+ var LOCAL_ASSET_SCHEME = "lecodesfile://";
1163
1254
  var DEVICE_SAFE_AREA = {
1164
1255
  none: [0, 0, 0, 0],
1165
1256
  iphone: [47, 0, 34, 0],
@@ -1196,12 +1287,16 @@ var render = async (args) => {
1196
1287
  const token = requireToken(config);
1197
1288
  note("Resolving project assets…");
1198
1289
  const project = await getProject(apiUrl, token, manifest.uuid);
1199
- const { entries, warnings } = collectEntries(root, manifest, project);
1290
+ const { entries, warnings: collectWarnings } = collectEntries(root, manifest, project);
1291
+ const publicUrlFlag = flagStr(args, "public-url");
1292
+ const useLocalAssets = !publicUrlFlag && !flagBool(args, "remote-assets");
1293
+ const compileEntries = useLocalAssets ? entries.map((e) => e.type === "resource" && e.absPath ? { ...e, fileSrc: encodeURIComponent(e.absPath) } : e) : entries;
1294
+ const warnings = useLocalAssets ? collectWarnings.filter((w) => !w.includes("No server URL")) : collectWarnings;
1200
1295
  note("Compiling…");
1201
1296
  const js = await compileProject2({
1202
- entries,
1297
+ entries: compileEntries,
1203
1298
  name: manifest.name,
1204
- publicUrl: flagStr(args, "public-url") ?? apiUrl,
1299
+ publicUrl: useLocalAssets ? LOCAL_ASSET_SCHEME : publicUrlFlag ?? apiUrl,
1205
1300
  entryOverride: flagStr(args, "entry"),
1206
1301
  header: false,
1207
1302
  format: "iife"
@@ -1209,6 +1304,9 @@ var render = async (args) => {
1209
1304
  if (js.includes("_creator.createScene") || js.includes("_creator.createEntity")) {
1210
1305
  warnings.push("This looks like a 3D/GL project — only the UI layer is rendered.");
1211
1306
  }
1307
+ if (js.includes("_creator2d") && !useLocalAssets && !flagBool(args, "wait-network")) {
1308
+ warnings.push("2D scene detected: sprite textures load over the network — add --wait-network to include them (else sprites render as flat placeholders).");
1309
+ }
1212
1310
  let renderer;
1213
1311
  try {
1214
1312
  renderer = await import("lecodes-renderer/headless");
@@ -1223,6 +1321,7 @@ var render = async (args) => {
1223
1321
  const timing = {
1224
1322
  settleMs: settleStr !== undefined ? Number(settleStr) : undefined,
1225
1323
  waitNetwork: flagBool(args, "wait-network"),
1324
+ localAssets: useLocalAssets,
1226
1325
  timeMs: timeStr !== undefined ? Number(timeStr) : undefined,
1227
1326
  afterAnimations: !flagBool(args, "no-after-animations"),
1228
1327
  safeArea: resolveSafeArea(args),
@@ -1356,7 +1455,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
1356
1455
 
1357
1456
  // src/commands/push.ts
1358
1457
  import { basename as basename3 } from "node:path";
1359
- import { readFileSync as readFileSync7 } from "node:fs";
1458
+ import { readFileSync as readFileSync8 } from "node:fs";
1360
1459
  var push = async (args) => {
1361
1460
  const root = findProjectRoot(process.cwd());
1362
1461
  const manifest = readManifest(root);
@@ -1381,6 +1480,12 @@ var push = async (args) => {
1381
1480
  if (commits.headId !== manifest.baseHeadId) {
1382
1481
  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
1482
  }
1483
+ if (commits.dirty) {
1484
+ const drift = serverPathDrift(await getProject(apiUrl, token, manifest.uuid), manifest);
1485
+ if (drift) {
1486
+ throw new CliError(`The project has uncommitted changes in the web editor your checkout doesn't have (${drift}). Run \`lecodes pull\` to take them, or \`lecodes push --force\` to overwrite the server.`);
1487
+ }
1488
+ }
1384
1489
  }
1385
1490
  if (force) {
1386
1491
  const project = await getProject(apiUrl, token, manifest.uuid);
@@ -1409,9 +1514,9 @@ var push = async (args) => {
1409
1514
  if (source)
1410
1515
  entry.assetId = source.id;
1411
1516
  if (isTextKind(kind)) {
1412
- entry.text = readFileSync7(file.absPath, "utf8");
1517
+ entry.text = readFileSync8(file.absPath, "utf8");
1413
1518
  } else if (!source || source.sha !== file.sha) {
1414
- const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename3(file.path), readFileSync7(file.absPath));
1519
+ const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename3(file.path), readFileSync8(file.absPath));
1415
1520
  entry.blobFileId = blob.id;
1416
1521
  uploaded++;
1417
1522
  }
@@ -1443,6 +1548,19 @@ var push = async (args) => {
1443
1548
  if (publish)
1444
1549
  reportPublish(res);
1445
1550
  };
1551
+ var serverPathDrift = (project, manifest) => {
1552
+ const pathById = buildPaths(project.assets);
1553
+ const serverPaths = new Set(project.assets.filter((a) => a.type !== "folder").map((a) => pathById.get(a.id)));
1554
+ const manifestPaths = new Set(Object.keys(manifest.files));
1555
+ const added = [...serverPaths].filter((p) => !manifestPaths.has(p));
1556
+ const removed = [...manifestPaths].filter((p) => !serverPaths.has(p));
1557
+ const describe = (label, paths) => `${paths.length} ${label} on the server: ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", …" : ""}`;
1558
+ const parts = [
1559
+ added.length ? describe("added", added) : "",
1560
+ removed.length ? describe("removed", removed) : ""
1561
+ ].filter(Boolean);
1562
+ return parts.length ? parts.join("; ") : null;
1563
+ };
1446
1564
  var reportPublish = (res) => {
1447
1565
  if (res.published)
1448
1566
  success("Published — the project is now live.");
@@ -1527,8 +1645,8 @@ Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKE
1527
1645
  var commands = { login, create, clone, status, diff, pull, push, preview, render };
1528
1646
  var readVersion = () => {
1529
1647
  try {
1530
- const here = dirname7(fileURLToPath3(import.meta.url));
1531
- return JSON.parse(readFileSync8(join11(here, "..", "package.json"), "utf8")).version ?? "unknown";
1648
+ const here2 = dirname7(fileURLToPath3(import.meta.url));
1649
+ return JSON.parse(readFileSync9(join11(here2, "..", "package.json"), "utf8")).version ?? "unknown";
1532
1650
  } catch {
1533
1651
  return "unknown";
1534
1652
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Clone, edit and push LeCodes projects from your machine.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,16 +21,16 @@
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-renderer --banner=\"#!/usr/bin/env node\"",
24
+ "build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --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"
28
28
  },
29
29
  "dependencies": {
30
- "esbuild": "^0.27.3"
30
+ "@letary/chisel": "^0.1.2"
31
31
  },
32
32
  "peerDependencies": {
33
- "lecodes-renderer": "^0.1.1"
33
+ "lecodes-renderer": "^0.2.0"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "lecodes-renderer": {