lecodes-cli 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +4 -0
  2. package/dist/index.js +677 -230
  3. package/package.json +2 -2
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/compile/aspectMacro.ts +42 -0
  6. package/runtime/sdk/compile/assetMacro.ts +45 -0
  7. package/runtime/sdk/compile/bundler.ts +239 -0
  8. package/runtime/sdk/compile/compileProject.ts +104 -0
  9. package/runtime/sdk/compile/detectEntry.ts +125 -0
  10. package/runtime/sdk/compile/fontMacro.ts +459 -0
  11. package/runtime/sdk/compile/fontRegistry.ts +78 -0
  12. package/runtime/sdk/compile/header.ts +51 -0
  13. package/runtime/sdk/compile/index.ts +38 -0
  14. package/runtime/sdk/compile/libraryImports.ts +52 -0
  15. package/runtime/sdk/compile/sceneEditor.ts +78 -0
  16. package/runtime/sdk/compile/sfnt.ts +98 -0
  17. package/runtime/sdk/compile/sourcemap.ts +25 -0
  18. package/runtime/sdk/gl/Material.ts +52 -0
  19. package/runtime/sdk/gl/Particles.ts +477 -116
  20. package/runtime/sdk/inject.ts +1 -1
  21. package/runtime/sdk/ui/NativeView.ts +5 -5
  22. package/runtime/sdk/ui/UIButton.ts +5 -5
  23. package/runtime/sdk/ui/UIContainer.ts +13 -13
  24. package/runtime/sdk/ui/UIImage.ts +5 -5
  25. package/runtime/sdk/ui/UIInput.ts +9 -9
  26. package/runtime/sdk/ui/UINode.ts +114 -34
  27. package/runtime/sdk/ui/UIPager.ts +5 -9
  28. package/runtime/sdk/ui/UIScreen.ts +5 -5
  29. package/runtime/sdk/ui/UIScrollable.ts +5 -5
  30. package/runtime/sdk/ui/UISpacer.ts +5 -5
  31. package/runtime/sdk/ui/UIText.ts +5 -5
  32. package/runtime/sdk/ui/UIVideo.ts +5 -5
  33. package/runtime/sdk/ui/UIVirtualizedList.ts +5 -5
  34. package/runtime/sdk/ui/UIWidget.ts +5 -5
  35. package/runtime/sdk-types.json +1 -1
package/dist/index.js CHANGED
@@ -247,7 +247,7 @@ var resolveInject = () => {
247
247
  inject: ["/__sdk/inject.ts"],
248
248
  format,
249
249
  minify,
250
- fuse: process.env.LECODES_CHISEL_FUSE === "1",
250
+ fuse: process.env.LECODES_CHISEL_FUSE !== "0",
251
251
  define: { ...CHISEL_DEFINE, EDITOR: editor ? "true" : "false" },
252
252
  keep: CHISEL_KEEP,
253
253
  sourcemap,
@@ -268,7 +268,10 @@ var resolveInject = () => {
268
268
  throw new Error(`chisel: ${parsed.error}`);
269
269
  if (parsed.code == null)
270
270
  throw new Error("chisel produced no code");
271
- return { code: parsed.code, map: parsed.map ?? null };
271
+ const diagnostics = parsed.diagnostics ?? [];
272
+ for (const d of diagnostics)
273
+ console.warn(`warning: ${d}`);
274
+ return { code: parsed.code, map: parsed.map ?? null, diagnostics };
272
275
  }, bundleProject = async (entrypoint, files, format = "esm") => (await bundleProjectChisel(entrypoint, files, format, false)).code, bundleProjectWithMap = async (entrypoint, files, format = "esm", minify, editor, libraries, sourcemap = true) => bundleProjectChisel(entrypoint, files, format, sourcemap, minify, editor, libraries);
273
276
  var init_bundler = __esm(() => {
274
277
  init_assetMacro();
@@ -2088,9 +2091,9 @@ var require_main = __commonJS((exports, module) => {
2088
2091
  // package.json
2089
2092
  var package_default = {
2090
2093
  name: "lecodes-cli",
2091
- version: "0.9.2",
2094
+ version: "0.10.1",
2092
2095
  dependencies: {
2093
- "@letary/chisel": "^0.5.1"
2096
+ "@letary/chisel": "^0.6.0"
2094
2097
  },
2095
2098
  devDependencies: {
2096
2099
  "lecodes-design": "workspace:*",
@@ -3472,6 +3475,11 @@ ${keys.map((k) => ` <key>${k}</key>
3472
3475
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3473
3476
  <plist version="1.0">
3474
3477
  <dict>
3478
+ <!-- Standard-encryption-only (HTTPS/TLS): declares export-compliance exemption
3479
+ so App Store Connect skips the "Missing Compliance" prompt on every build.
3480
+ Flip to <true/> only if the app adds its own non-exempt cryptography. -->
3481
+ <key>ITSAppUsesNonExemptEncryption</key>
3482
+ <false/>
3475
3483
  <key>UIApplicationSceneManifest</key>
3476
3484
  <dict>
3477
3485
  <key>UIApplicationSupportsMultipleScenes</key>
@@ -3588,8 +3596,9 @@ var runtimePackageTemplate = (sdkVersion, variant, plugins) => {
3588
3596
  //
3589
3597
  // GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
3590
3598
  // Owns the app's native dependency graph: the LeCodesSDK variant (auto-picked from what
3591
- // the compiled bundle uses, or forced via app.json ios.variant) and one vendored target
3592
- // per installed plugin (see plugins.lock.json).
3599
+ // the compiled bundle uses, or forced via app.json ios.variant), the exact SDK release
3600
+ // (app.json ios.sdk — move it with \`lecodes app update\`) and one vendored target per
3601
+ // installed plugin (see plugins.lock.json).
3593
3602
 
3594
3603
  import PackageDescription
3595
3604
 
@@ -3600,7 +3609,7 @@ let package = Package(
3600
3609
  .library(name: "LeCodesRuntime", targets: ["LeCodesRuntime"]),
3601
3610
  ],
3602
3611
  dependencies: [
3603
- .package(url: "https://github.com/letary/lecodes-ios-sdk.git", from: "${sdkVersion}"),
3612
+ .package(url: "https://github.com/letary/lecodes-ios-sdk.git", exact: "${sdkVersion}"),
3604
3613
  ],
3605
3614
  targets: [
3606
3615
  .target(
@@ -3653,6 +3662,22 @@ lecodes app open # open App.xcodeproj in Xcode — build & run from there
3653
3662
  \`lecodes app doctor\` preflights builds; \`lecodes app build ios\` archives and exports an
3654
3663
  .ipa; \`lecodes app upload ios\` sends the archive to App Store Connect.
3655
3664
 
3665
+ ## SDK version
3666
+
3667
+ \`../app.json\` \`ios.sdk\` is the exact \`lecodes-ios-sdk\` release this shell links —
3668
+ \`lecodes app init\` pinned the newest one at the time, so builds stay reproducible.
3669
+
3670
+ \`\`\`
3671
+ lecodes app update # → newest SDK release + newest plugin tags, then sync
3672
+ lecodes app update --check # what's available, writes nothing
3673
+ lecodes app update --sdk 1.3.0
3674
+ \`\`\`
3675
+
3676
+ Set \`"sdk": "latest"\` instead of a version to re-resolve on every sync (needs network;
3677
+ offline it keeps whatever the shell already links). The pin is exact rather than a range
3678
+ because the CLI also downloads that release's filament resources bundle into
3679
+ \`App/Resources/\` — the two halves of one SDK must not drift apart.
3680
+
3656
3681
  ## Who owns what
3657
3682
 
3658
3683
  Yours — \`lecodes app sync\` never touches these:
@@ -3700,7 +3725,8 @@ buildNumber) — edit it there, then re-run \`lecodes app sync\`. Plugins are ma
3700
3725
  `;
3701
3726
 
3702
3727
  // src/commands/app.ts
3703
- var SDK_PACKAGE_VERSION = "1.2.0";
3728
+ var SDK_REPO = "letary/lecodes-ios-sdk";
3729
+ var SDK_MIN_VERSION = "1.2.0";
3704
3730
  var SDK_VARIANTS = ["core", "2d", "3d", "full"];
3705
3731
  var FIRST_PARTY_PLUGINS = {
3706
3732
  camera: "letary/lecodes-plugins",
@@ -3725,6 +3751,51 @@ var projectRoot = () => {
3725
3751
  }
3726
3752
  };
3727
3753
  var git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
3754
+ var VERSION_RE = /^\d+\.\d+\.\d+$/;
3755
+ var cmpVersion = (a, b) => {
3756
+ const pa = a.split(".").map(Number);
3757
+ const pb = b.split(".").map(Number);
3758
+ for (let i = 0;i < 3; i++) {
3759
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
3760
+ return (pa[i] ?? 0) - (pb[i] ?? 0);
3761
+ }
3762
+ return 0;
3763
+ };
3764
+ var fetchLatestSdkVersion = async () => {
3765
+ const url = `https://api.github.com/repos/${SDK_REPO}/releases/latest`;
3766
+ const res = await fetch(url, { headers: { accept: "application/vnd.github+json", "user-agent": "lecodes-cli" } });
3767
+ if (!res.ok)
3768
+ throw new CliError(`Could not read the latest ${SDK_REPO} release (HTTP ${res.status}) — ${url}`);
3769
+ const tag = (await res.json()).tag_name?.replace(/^v/, "") ?? "";
3770
+ if (!VERSION_RE.test(tag))
3771
+ throw new CliError(`${SDK_REPO} latest release has no x.y.z tag (got "${tag}")`);
3772
+ return tag;
3773
+ };
3774
+ var shellSdkVersion = (iosDir) => {
3775
+ const path = join11(iosDir, "LeCodesRuntime", "Package.swift");
3776
+ if (!existsSync8(path))
3777
+ return null;
3778
+ const m = /lecodes-ios-sdk\.git",\s*(?:exact|from):\s*"([^"]+)"/.exec(readFileSync10(path, "utf8"));
3779
+ return m && VERSION_RE.test(m[1]) ? m[1] : null;
3780
+ };
3781
+ var resolveSdkVersion = async (pin, fallback) => {
3782
+ if (pin !== "latest") {
3783
+ if (!VERSION_RE.test(pin)) {
3784
+ throw new CliError(`app.json ios.sdk must be "latest" or an exact x.y.z release (got "${pin}").`);
3785
+ }
3786
+ if (cmpVersion(pin, SDK_MIN_VERSION) < 0) {
3787
+ throw new CliError(`app.json ios.sdk ${pin} is older than the ${SDK_MIN_VERSION} these generated shells need.`);
3788
+ }
3789
+ return pin;
3790
+ }
3791
+ try {
3792
+ return await fetchLatestSdkVersion();
3793
+ } catch (e) {
3794
+ const kept = fallback ?? SDK_MIN_VERSION;
3795
+ warn(`Could not resolve the latest SDK (${e.message}) — staying on ${kept}.`);
3796
+ return kept;
3797
+ }
3798
+ };
3728
3799
  var ensureRepo = (repo, wantTag, lock) => {
3729
3800
  const override = process.env.LECODES_PLUGINS_DIR;
3730
3801
  if (override) {
@@ -3802,7 +3873,7 @@ var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+
3802
3873
  var appInit = async (args) => {
3803
3874
  const platform = args._[1];
3804
3875
  if (platform !== "ios") {
3805
- throw new CliError(platform ? `Unsupported platform "${platform}" — only "ios" for now.` : "Usage: lecodes app init ios [--name <display name>] [--bundle-id <id>] [--team <team id>] [--variant auto|core|2d|3d|full]");
3876
+ throw new CliError(platform ? `Unsupported platform "${platform}" — only "ios" for now.` : "Usage: lecodes app init ios [--name <display name>] [--bundle-id <id>] [--team <team id>] [--variant auto|core|2d|3d|full] [--sdk <version|latest>]");
3806
3877
  }
3807
3878
  const root = projectRoot();
3808
3879
  const iosDir = join11(root, "ios");
@@ -3814,6 +3885,9 @@ var appInit = async (args) => {
3814
3885
  if (variant !== "auto" && !SDK_VARIANTS.includes(variant)) {
3815
3886
  throw new CliError(`--variant must be auto, ${SDK_VARIANTS.join(", ")} (got "${variant}")`);
3816
3887
  }
3888
+ const sdkFlag = flagStr(args, "sdk") ?? existing.ios?.sdk;
3889
+ const sdkVersion = await resolveSdkVersion(process.env.LECODES_IOS_SDK ?? sdkFlag ?? "latest", null);
3890
+ const sdk = sdkFlag === "latest" ? "latest" : sdkVersion;
3817
3891
  const name = flagStr(args, "name") ?? existing.name ?? basename4(root);
3818
3892
  const config = {
3819
3893
  ...existing,
@@ -3824,6 +3898,7 @@ var appInit = async (args) => {
3824
3898
  bundleId: flagStr(args, "bundle-id") ?? existing.ios?.bundleId ?? `com.example.${slugify(name)}`,
3825
3899
  teamId: flagStr(args, "team") ?? existing.ios?.teamId ?? "",
3826
3900
  variant,
3901
+ sdk,
3827
3902
  buildNumber: existing.ios?.buildNumber ?? 1
3828
3903
  }
3829
3904
  };
@@ -3863,9 +3938,10 @@ ios/
3863
3938
  write("App/Assets.xcassets/Contents.json", ASSETS_ROOT_JSON);
3864
3939
  write("App/Assets.xcassets/AccentColor.colorset/Contents.json", ACCENT_COLOR_JSON);
3865
3940
  write("App/Assets.xcassets/AppIcon.appiconset/Contents.json", APP_ICON_JSON);
3866
- write("LeCodesRuntime/Package.swift", runtimePackageTemplate(SDK_PACKAGE_VERSION, variant === "auto" ? "core" : variant, []));
3941
+ write("LeCodesRuntime/Package.swift", runtimePackageTemplate(sdkVersion, variant === "auto" ? "core" : variant, []));
3867
3942
  write("LeCodesRuntime/Sources/LeCodesRuntime/LeCodesRuntime.swift", runtimeSwiftTemplate([]));
3868
3943
  log("");
3944
+ log(`${c.green("sdk")} lecodes-ios-sdk ${sdkVersion}${sdk === "latest" ? c.dim(" (ios.sdk: latest — re-resolved on every sync)") : c.dim(" (pinned in app.json ios.sdk — `lecodes app update` moves it)")}`);
3869
3945
  success(`iOS shell for ${c.bold(name)} ready.`);
3870
3946
  log(` ${c.bold("lecodes app sync")} embed the compiled bundle (+ plugins) into the shell`);
3871
3947
  log(` ${c.bold("open ios/App.xcodeproj")} then run on your device from Xcode`);
@@ -3898,18 +3974,18 @@ var resolveVariant = (requested, bundleJs) => {
3898
3974
  }
3899
3975
  return { variant, detected };
3900
3976
  };
3901
- var ensureSdkResourcesBundle = async () => {
3977
+ var ensureSdkResourcesBundle = async (sdkVersion) => {
3902
3978
  const override = process.env.LECODES_SDK_RESOURCES;
3903
3979
  if (override) {
3904
3980
  note(`Using local resources bundle ${override} (LECODES_SDK_RESOURCES)`);
3905
3981
  return resolve2(override);
3906
3982
  }
3907
- const dir = join11(homedir2(), ".lecodes", "sdk-resources", SDK_PACKAGE_VERSION);
3983
+ const dir = join11(homedir2(), ".lecodes", "sdk-resources", sdkVersion);
3908
3984
  const bundle = join11(dir, "LeCodesSDKResources.bundle");
3909
3985
  if (existsSync8(bundle))
3910
3986
  return bundle;
3911
- const url = `https://github.com/letary/lecodes-ios-sdk/releases/download/${SDK_PACKAGE_VERSION}/LeCodesSDKResources.bundle.zip`;
3912
- note(`Downloading LeCodesSDKResources.bundle (SDK ${SDK_PACKAGE_VERSION})…`);
3987
+ const url = `https://github.com/${SDK_REPO}/releases/download/${sdkVersion}/LeCodesSDKResources.bundle.zip`;
3988
+ note(`Downloading LeCodesSDKResources.bundle (SDK ${sdkVersion})…`);
3913
3989
  const res = await fetch(url);
3914
3990
  if (!res.ok)
3915
3991
  throw new CliError(`Resources bundle download failed (HTTP ${res.status}) — ${url}`);
@@ -3952,6 +4028,9 @@ var appSync = async (_args) => {
3952
4028
  if (requestedVariant !== "auto" && !SDK_VARIANTS.includes(requestedVariant)) {
3953
4029
  throw new CliError(`app.json ios.variant must be "auto" or one of ${SDK_VARIANTS.join(", ")} (got "${requestedVariant}").`);
3954
4030
  }
4031
+ const linkedSdk = shellSdkVersion(iosDir);
4032
+ const sdkPin = process.env.LECODES_IOS_SDK ?? config.ios.sdk ?? linkedSdk ?? "latest";
4033
+ const sdkVersion = await resolveSdkVersion(sdkPin, linkedSdk);
3955
4034
  const lockPath = join11(root, "plugins.lock.json");
3956
4035
  const oldLock = readJson(lockPath)?.plugins ?? {};
3957
4036
  const pluginSpecs = Object.entries(config.plugins ?? {});
@@ -3972,6 +4051,9 @@ var appSync = async (_args) => {
3972
4051
  const dest = join11(sourcesDir, module);
3973
4052
  rmSync(dest, { recursive: true, force: true });
3974
4053
  cpSync(src, dest, { recursive: true });
4054
+ if (ios.minSdk && VERSION_RE.test(ios.minSdk) && cmpVersion(sdkVersion, ios.minSdk) < 0) {
4055
+ warn(`Plugin "${id}" needs lecodes-ios-sdk ${ios.minSdk} but the shell links ${sdkVersion} — run \`lecodes app update\` (or set app.json ios.sdk).`);
4056
+ }
3975
4057
  for (const [key, value] of Object.entries(ios.infoPlist ?? {})) {
3976
4058
  (infoValues[key] ??= []).push(value);
3977
4059
  }
@@ -4003,9 +4085,11 @@ var appSync = async (_args) => {
4003
4085
  full: "uses the 3D + 2D engines"
4004
4086
  };
4005
4087
  log(`${c.green("variant")} ${variant}${requestedVariant === "auto" ? ` (auto: ${WHY[detected]})` : " (forced by ios.variant)"}`);
4088
+ const sdkWhy = sdkPin === "latest" ? "latest" : config.ios.sdk ? "pinned by ios.sdk" : "from the shell — set ios.sdk or run `lecodes app update`";
4089
+ log(`${c.green("sdk")} lecodes-ios-sdk ${sdkVersion}${c.dim(` (${sdkWhy})`)}`);
4006
4090
  const sdkProvided = new Set;
4007
4091
  if (variant === "3d" || variant === "full") {
4008
- const sdkResources = await ensureSdkResourcesBundle();
4092
+ const sdkResources = await ensureSdkResourcesBundle(sdkVersion);
4009
4093
  cpSync(sdkResources, join11(resourcesDir, "LeCodesSDKResources.bundle"), { recursive: true });
4010
4094
  for (const f2 of readdirSync4(sdkResources))
4011
4095
  if (f2 !== "Info.plist")
@@ -4043,7 +4127,7 @@ var appSync = async (_args) => {
4043
4127
  if (streamed.length > 0)
4044
4128
  log(`${c.green("remote")} ${streamed.length} resource(s) stay on the server (resources.remote): ${streamed.map((r) => r.path).join(", ")}`);
4045
4129
  writeFileSync7(join11(iosDir, "README.md"), IOS_README);
4046
- writeFileSync7(join11(iosDir, "LeCodesRuntime", "Package.swift"), runtimePackageTemplate(SDK_PACKAGE_VERSION, variant, runtimePlugins));
4130
+ writeFileSync7(join11(iosDir, "LeCodesRuntime", "Package.swift"), runtimePackageTemplate(sdkVersion, variant, runtimePlugins));
4047
4131
  writeFileSync7(join11(sourcesDir, "LeCodesRuntime", "LeCodesRuntime.swift"), runtimeSwiftTemplate(runtimePlugins));
4048
4132
  const mergedKeys = {};
4049
4133
  for (const [key, values] of Object.entries(infoValues))
@@ -4058,7 +4142,7 @@ var appSync = async (_args) => {
4058
4142
  const pbx = readFileSync10(pbxPath, "utf8").replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${config.version ?? "1.0.0"};`).replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${config.ios.buildNumber ?? 1};`);
4059
4143
  writeFileSync7(pbxPath, pbx);
4060
4144
  log("");
4061
- success(`Shell synced: app.js + ${runtimePlugins.length} plugin(s), variant ${variant}, v${config.version ?? "1.0.0"} (${config.ios.buildNumber ?? 1}).`);
4145
+ success(`Shell synced: app.js + ${runtimePlugins.length} plugin(s), SDK ${sdkVersion}, variant ${variant}, v${config.version ?? "1.0.0"} (${config.ios.buildNumber ?? 1}).`);
4062
4146
  note("Build & run from Xcode (open ios/App.xcodeproj) — user files in ios/App/*.swift were not touched.");
4063
4147
  };
4064
4148
  var appPlugin = async (args) => {
@@ -4147,6 +4231,95 @@ var requireShell = () => {
4147
4231
  }
4148
4232
  return { root, config };
4149
4233
  };
4234
+ var latestPluginTag = (repo) => {
4235
+ try {
4236
+ return ensureRepo(repo, null, undefined).tag;
4237
+ } catch {
4238
+ return null;
4239
+ }
4240
+ };
4241
+ var appUpdate = async (args) => {
4242
+ const { root, config } = requireShell();
4243
+ const iosDir = join11(root, "ios");
4244
+ const checkOnly = flagBool(args, "check");
4245
+ const wanted = flagStr(args, "sdk");
4246
+ let changed = false;
4247
+ const pin = config.ios.sdk ?? shellSdkVersion(iosDir) ?? "latest";
4248
+ const current = pin === "latest" ? shellSdkVersion(iosDir) ?? SDK_MIN_VERSION : pin;
4249
+ let nextPin;
4250
+ if (wanted === "latest") {
4251
+ nextPin = "latest";
4252
+ } else if (wanted) {
4253
+ if (!VERSION_RE.test(wanted))
4254
+ throw new CliError(`--sdk must be "latest" or an exact x.y.z release (got "${wanted}").`);
4255
+ if (cmpVersion(wanted, SDK_MIN_VERSION) < 0)
4256
+ throw new CliError(`--sdk ${wanted} is older than the ${SDK_MIN_VERSION} these generated shells need.`);
4257
+ nextPin = wanted;
4258
+ } else {
4259
+ nextPin = await fetchLatestSdkVersion();
4260
+ }
4261
+ const nextVersion = nextPin === "latest" ? await fetchLatestSdkVersion() : nextPin;
4262
+ if (nextVersion === current && nextPin === pin) {
4263
+ log(`${c.green("sdk")} lecodes-ios-sdk ${current} ${c.dim("(already current)")}`);
4264
+ } else if (nextVersion === current) {
4265
+ log(`${c.green("sdk")} lecodes-ios-sdk ${current} → ios.sdk ${nextPin} ${c.dim("(same version, pin mode changed)")}`);
4266
+ changed = true;
4267
+ } else {
4268
+ const verb = cmpVersion(nextVersion, current) < 0 ? c.yellow("downgrade") : c.green("update");
4269
+ log(`${c.green("sdk")} lecodes-ios-sdk ${current} → ${c.bold(nextVersion)} ${c.dim(`(${verb})`)}`);
4270
+ changed = true;
4271
+ }
4272
+ if (!checkOnly && nextPin !== pin) {
4273
+ config.ios.sdk = nextPin;
4274
+ writeJson(join11(root, "app.json"), config);
4275
+ }
4276
+ const lockPath = join11(root, "plugins.lock.json");
4277
+ const lock = readJson(lockPath)?.plugins ?? {};
4278
+ const specs = Object.entries(config.plugins ?? {});
4279
+ if (specs.length > 0 && !flagBool(args, "no-plugins")) {
4280
+ const kept = {};
4281
+ for (const [id, spec] of specs) {
4282
+ const entry = lock[id];
4283
+ if (spec.startsWith("github:") && spec.includes("@")) {
4284
+ log(`${c.green("plugin")} ${id} ${c.dim(`pinned by app.json (${spec}) — not updated`)}`);
4285
+ if (entry)
4286
+ kept[id] = entry;
4287
+ continue;
4288
+ }
4289
+ const repo = spec.startsWith("github:") ? spec.slice("github:".length) : FIRST_PARTY_PLUGINS[id];
4290
+ const tag = repo ? latestPluginTag(repo) : null;
4291
+ if (!tag) {
4292
+ log(`${c.yellow("plugin")} ${id} ${c.dim("could not read its latest tag — left as is")}`);
4293
+ if (entry)
4294
+ kept[id] = entry;
4295
+ continue;
4296
+ }
4297
+ if (entry && entry.tag === tag) {
4298
+ log(`${c.green("plugin")} ${id} ${entry.tag} ${c.dim("(already current)")}`);
4299
+ kept[id] = entry;
4300
+ continue;
4301
+ }
4302
+ log(`${c.green("plugin")} ${id} ${entry?.tag ?? "—"} → ${c.bold(tag)}`);
4303
+ changed = true;
4304
+ }
4305
+ if (!checkOnly && existsSync8(lockPath))
4306
+ writeJson(lockPath, { plugins: kept });
4307
+ }
4308
+ log("");
4309
+ if (checkOnly) {
4310
+ note(changed ? "Run `lecodes app update` to apply." : "Everything is up to date.");
4311
+ return;
4312
+ }
4313
+ if (!changed) {
4314
+ success("Already up to date.");
4315
+ return;
4316
+ }
4317
+ if (flagBool(args, "no-sync")) {
4318
+ success("Pins updated — run `lecodes app sync` to apply them to the shell.");
4319
+ return;
4320
+ }
4321
+ await appSync({ _: [], flags: {} });
4322
+ };
4150
4323
  var appDoctor = async (_args) => {
4151
4324
  const { root, config } = requireShell();
4152
4325
  let failed = 0;
@@ -4177,6 +4350,18 @@ var appDoctor = async (_args) => {
4177
4350
  const hasResources = existsSync8(join11(root, "ios", "App", "Resources", "LeCodesSDKResources.bundle"));
4178
4351
  check(pkg.includes(`"LeCodesSDK-${variant}"`) && (!needsResources || hasResources), `SDK variant ${variant}${needsResources ? " (+ filament resources)" : ""}`, "run `lecodes app sync`");
4179
4352
  }
4353
+ const linked = shellSdkVersion(join11(root, "ios"));
4354
+ const pin = config.ios.sdk ?? "latest";
4355
+ check(!!linked, `SDK ${linked ?? "version unreadable"}`, "run `lecodes app sync`");
4356
+ if (linked && pin !== "latest") {
4357
+ check(linked === pin, `SDK matches app.json ios.sdk (${pin})`, "run `lecodes app sync`");
4358
+ }
4359
+ if (linked) {
4360
+ try {
4361
+ const latest = await fetchLatestSdkVersion();
4362
+ check(cmpVersion(linked, latest) >= 0, `SDK is the latest release (${latest})`, "run `lecodes app update`", false);
4363
+ } catch {}
4364
+ }
4180
4365
  const lock = readJson(join11(root, "plugins.lock.json"))?.plugins ?? {};
4181
4366
  const wanted = Object.keys(config.plugins ?? {});
4182
4367
  const inSync = wanted.every((id) => lock[id]) && Object.keys(lock).every((id) => wanted.includes(id));
@@ -4303,6 +4488,8 @@ var app = async (args) => {
4303
4488
  return appInit(args);
4304
4489
  if (sub === "sync")
4305
4490
  return appSync(args);
4491
+ if (sub === "update")
4492
+ return appUpdate(args);
4306
4493
  if (sub === "plugin")
4307
4494
  return appPlugin(args);
4308
4495
  if (sub === "doctor")
@@ -4313,16 +4500,16 @@ var app = async (args) => {
4313
4500
  return appUpload(args);
4314
4501
  if (sub === "open")
4315
4502
  return appOpen(args);
4316
- throw new CliError("Usage: lecodes app <init ios | sync | open | plugin list|add|remove | doctor | build ios | upload ios> — see `lecodes help`.");
4503
+ throw new CliError("Usage: lecodes app <init ios | sync | update | open | plugin list|add|remove | doctor | build ios | upload ios> — see `lecodes help`.");
4317
4504
  };
4318
4505
 
4319
4506
  // src/commands/clone.ts
4320
- import { existsSync as existsSync10, mkdirSync as mkdirSync7, readdirSync as readdirSync5 } from "node:fs";
4507
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, readdirSync as readdirSync6 } from "node:fs";
4321
4508
  import { resolve as resolve3 } from "node:path";
4322
4509
 
4323
4510
  // src/types.ts
4324
- import { dirname as dirname8, join as join12 } from "node:path";
4325
- import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "node:fs";
4511
+ import { dirname as dirname8, join as join12, relative as relative3, sep } from "node:path";
4512
+ import { existsSync as existsSync9, mkdirSync as mkdirSync6, readdirSync as readdirSync5, readFileSync as readFileSync11, rmSync as rmSync2, statSync as statSync3, writeFileSync as writeFileSync8 } from "node:fs";
4326
4513
  var TSCONFIG = {
4327
4514
  compilerOptions: {
4328
4515
  target: "ES2020",
@@ -4339,17 +4526,60 @@ var TSCONFIG = {
4339
4526
  include: [`${LECODES_DIR}/types/**/*.d.ts`, "**/*.ts", "**/*.js"],
4340
4527
  exclude: ["node_modules"]
4341
4528
  };
4529
+ var listBundleFiles = (dir, base = dir) => {
4530
+ if (!existsSync9(dir))
4531
+ return [];
4532
+ const out = [];
4533
+ for (const name of readdirSync5(dir)) {
4534
+ const path = join12(dir, name);
4535
+ if (statSync3(path).isDirectory())
4536
+ out.push(...listBundleFiles(path, base));
4537
+ else
4538
+ out.push(relative3(base, path).split(sep).join("/"));
4539
+ }
4540
+ return out;
4541
+ };
4542
+ var pruneEmptyDirs = (dir, root) => {
4543
+ if (!existsSync9(dir) || !statSync3(dir).isDirectory())
4544
+ return;
4545
+ for (const name of readdirSync5(dir))
4546
+ pruneEmptyDirs(join12(dir, name), root);
4547
+ if (dir !== root && readdirSync5(dir).length === 0)
4548
+ rmSync2(dir, { recursive: true });
4549
+ };
4342
4550
  var writeTypeBundle = (root, bundle) => {
4343
4551
  const typesDir = join12(root, LECODES_DIR, "types");
4552
+ const before = new Set(listBundleFiles(typesDir));
4553
+ const stats = { added: 0, updated: 0, removed: 0, total: Object.keys(bundle).length };
4344
4554
  for (const [path, text] of Object.entries(bundle)) {
4345
4555
  const dest = join12(typesDir, path);
4556
+ if (!before.has(path))
4557
+ stats.added++;
4558
+ else if (readFileSync11(dest, "utf8") !== text)
4559
+ stats.updated++;
4560
+ else
4561
+ continue;
4346
4562
  mkdirSync6(dirname8(dest), { recursive: true });
4347
4563
  writeFileSync8(dest, text);
4348
4564
  }
4565
+ for (const path of before) {
4566
+ if (path in bundle)
4567
+ continue;
4568
+ rmSync2(join12(typesDir, path));
4569
+ stats.removed++;
4570
+ }
4571
+ pruneEmptyDirs(typesDir, typesDir);
4572
+ return stats;
4349
4573
  };
4350
- var materializeTypes = async (root, apiUrl) => {
4351
- writeTypeBundle(root, await getTypes(apiUrl));
4574
+ var bundleHash = (bundle) => sha256(Object.keys(bundle).sort().map((k) => `${k}\x00${bundle[k]}`).join("\x00"));
4575
+ var readProjectTypeBundle = (root) => {
4576
+ const typesDir = join12(root, LECODES_DIR, "types");
4577
+ const bundle = {};
4578
+ for (const path of listBundleFiles(typesDir))
4579
+ bundle[path] = readFileSync11(join12(typesDir, path), "utf8");
4580
+ return bundle;
4352
4581
  };
4582
+ var materializeTypes = async (root, apiUrl) => writeTypeBundle(root, await getTypes(apiUrl));
4353
4583
  var readLocalTypeBundle = () => {
4354
4584
  const candidates = [
4355
4585
  join12(distRoot, "runtime", "sdk-types.json"),
@@ -4394,7 +4624,7 @@ var clone = async (args) => {
4394
4624
  const project = await getProject(apiUrl, token, uuid);
4395
4625
  const dir = args._[1] ?? slug(project.name);
4396
4626
  const root = resolve3(dir);
4397
- if (existsSync10(root) && readdirSync5(root).length > 0) {
4627
+ if (existsSync10(root) && readdirSync6(root).length > 0) {
4398
4628
  throw new CliError(`Directory "${dir}" already exists and is not empty.`);
4399
4629
  }
4400
4630
  mkdirSync7(root, { recursive: true });
@@ -4414,7 +4644,7 @@ var clone = async (args) => {
4414
4644
  };
4415
4645
 
4416
4646
  // src/commands/create.ts
4417
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync9 } from "node:fs";
4647
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readdirSync as readdirSync7, writeFileSync as writeFileSync9 } from "node:fs";
4418
4648
  import { join as join13, resolve as resolve4 } from "node:path";
4419
4649
 
4420
4650
  // src/commands/projectTemplates.ts
@@ -4501,7 +4731,7 @@ var create = async (args) => {
4501
4731
  throw new CliError("Usage: lecodes create <name> [directory]");
4502
4732
  const dir = args._[1] ?? slug2(name);
4503
4733
  const root = resolve4(dir);
4504
- if (existsSync11(root) && readdirSync6(root).length > 0) {
4734
+ if (existsSync11(root) && readdirSync7(root).length > 0) {
4505
4735
  throw new CliError(`Directory "${dir}" already exists and is not empty.`);
4506
4736
  }
4507
4737
  mkdirSync8(root, { recursive: true });
@@ -4520,7 +4750,7 @@ var create = async (args) => {
4520
4750
  };
4521
4751
 
4522
4752
  // src/commands/design.ts
4523
- import { existsSync as existsSync13, mkdirSync as mkdirSync10, readFileSync as readFileSync14, readdirSync as readdirSync7, writeFileSync as writeFileSync11, appendFileSync } from "node:fs";
4753
+ import { existsSync as existsSync13, mkdirSync as mkdirSync10, readFileSync as readFileSync14, readdirSync as readdirSync8, writeFileSync as writeFileSync11, appendFileSync } from "node:fs";
4524
4754
  import { basename as basename6, join as join15 } from "node:path";
4525
4755
 
4526
4756
  // src/compile/designCompile.ts
@@ -5507,7 +5737,7 @@ var listScreens = (designDir) => {
5507
5737
  const dir = join15(designDir, "screens");
5508
5738
  if (!existsSync13(dir))
5509
5739
  return [];
5510
- return readdirSync7(dir).filter((f2) => f2.endsWith(".ts") && !f2.startsWith(".")).map((f2) => f2.slice(0, -3)).sort();
5740
+ return readdirSync8(dir).filter((f2) => f2.endsWith(".ts") && !f2.startsWith(".")).map((f2) => f2.slice(0, -3)).sort();
5511
5741
  };
5512
5742
  var readMetaSizes = (designDir) => {
5513
5743
  try {
@@ -5876,7 +6106,7 @@ import { basename as basename7, join as join17 } from "node:path";
5876
6106
 
5877
6107
  // src/dev/devServer.ts
5878
6108
  import { createServer as createServer2 } from "node:http";
5879
- import { createReadStream, existsSync as existsSync14, statSync as statSync3 } from "node:fs";
6109
+ import { createReadStream, existsSync as existsSync14, statSync as statSync4 } from "node:fs";
5880
6110
  import { join as join16 } from "node:path";
5881
6111
 
5882
6112
  // src/dev/clientTemplates.ts
@@ -6247,7 +6477,7 @@ var startDevServer = async (options) => {
6247
6477
  return sendText(res, 400, "text/plain", "Bad path");
6248
6478
  }
6249
6479
  const abs = join16(root, key.slice(1));
6250
- if (!existsSync14(abs) || !statSync3(abs).isFile())
6480
+ if (!existsSync14(abs) || !statSync4(abs).isFile())
6251
6481
  return sendText(res, 404, "text/plain", "Not found");
6252
6482
  res.writeHead(200, { "content-type": contentType(key), "cache-control": "no-store" });
6253
6483
  createReadStream(abs).pipe(res);
@@ -6706,6 +6936,19 @@ lecodes test # run tests/*.flow.json — the starter ships w
6706
6936
  Scenario format + conventions: see [CLAUDE.md](./CLAUDE.md) (written for AI assistants, useful
6707
6937
  for humans too).
6708
6938
 
6939
+ ## Put it on the platform
6940
+
6941
+ This project is local-only. To give it a home on le.codes (web editor, sharing, OTA updates):
6942
+
6943
+ \`\`\`
6944
+ lecodes login # once per machine
6945
+ lecodes link # create a project from this folder and connect it
6946
+ lecodes push -m "initial" # upload it as the first checkpoint (--publish to go live)
6947
+ \`\`\`
6948
+
6949
+ Already made the project in the web editor? \`lecodes link --project <uuid>\` adopts it — it
6950
+ shows what your next push would change there before connecting anything.
6951
+
6709
6952
  ## Go native
6710
6953
 
6711
6954
  \`\`\`
@@ -6722,7 +6965,8 @@ lecodes app open # open it in Xcode, build & run
6722
6965
  \`tests/.artifacts/\`
6723
6966
  - \`CLAUDE.md\` — guidance for AI assistants (the headless feedback loop + testability conventions)
6724
6967
  - \`assets/\` — images/fonts the app references (created on demand)
6725
- - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit)
6968
+ - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit).
6969
+ They come from the SDK bundled in the CLI; \`lecodes types\` refreshes them from the platform.
6726
6970
  - \`.lecodesignore\` — files a future \`lecodes push\` would skip
6727
6971
 
6728
6972
  Full command list: \`lecodes --help\`.
@@ -6763,6 +7007,231 @@ var init2 = async (args) => {
6763
7007
  log(` ${c.bold(cd + "lecodes compile -o app.js")} bundle it for LeCodes Desktop`);
6764
7008
  };
6765
7009
 
7010
+ // src/commands/link.ts
7011
+ import { existsSync as existsSync17 } from "node:fs";
7012
+ import { basename as basename11, join as join19, resolve as resolve6 } from "node:path";
7013
+
7014
+ // src/commands/push.ts
7015
+ import { basename as basename10 } from "node:path";
7016
+ import { readFileSync as readFileSync17 } from "node:fs";
7017
+ var push = async (args) => {
7018
+ const root = findProjectRoot(process.cwd());
7019
+ const manifest = readManifest(root);
7020
+ const config = loadConfig();
7021
+ const apiUrl = requireApiUrl(config);
7022
+ const token = requireToken(config);
7023
+ const publish = flagBool(args, "publish");
7024
+ const scan = scanProject(root, manifest);
7025
+ const diff2 = diffLocal(manifest, scan);
7026
+ if (!hasChanges(diff2)) {
7027
+ if (publish) {
7028
+ await publishProject(apiUrl, token, manifest.uuid);
7029
+ success("No file changes — published the current project state.");
7030
+ } else {
7031
+ info(c.dim("Working tree clean — nothing to push."));
7032
+ }
7033
+ return;
7034
+ }
7035
+ const force = flagBool(args, "force");
7036
+ if (!force && manifest.baseHeadId !== null) {
7037
+ const commits = await getCommits(apiUrl, token, manifest.uuid);
7038
+ if (commits.headId !== manifest.baseHeadId) {
7039
+ 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.");
7040
+ }
7041
+ if (commits.dirty) {
7042
+ const drift = serverPathDrift(await getProject(apiUrl, token, manifest.uuid), manifest);
7043
+ if (drift) {
7044
+ 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.`);
7045
+ }
7046
+ }
7047
+ }
7048
+ if (force) {
7049
+ const project = await getProject(apiUrl, token, manifest.uuid);
7050
+ const serverDiff = computeServerDiff(project, scan);
7051
+ if (serverDiff.length) {
7052
+ info(c.yellow(`--force will overwrite the server. ${serverDiff.length} file${serverDiff.length === 1 ? "" : "s"} change:`));
7053
+ const label = { added: c.green(" create "), removed: c.red(" delete "), modified: c.yellow(" update ") };
7054
+ for (const e of serverDiff)
7055
+ log(label[e.status] + e.path + (e.binary ? c.dim(" (binary)") : ""));
7056
+ if (process.stdin.isTTY && !flagBool(args, "y", "yes")) {
7057
+ if (!await confirm("Overwrite the server with your local copy?", false)) {
7058
+ info(c.dim("Aborted."));
7059
+ return;
7060
+ }
7061
+ }
7062
+ }
7063
+ }
7064
+ const message = (flagStr(args, "m", "message") ?? await prompt("Checkpoint message")) || "Update from lecodes CLI";
7065
+ const sourceFor = (path) => manifest.files[path] ?? (diff2.moves.has(path) ? manifest.files[diff2.moves.get(path)] : undefined);
7066
+ const files = [];
7067
+ let uploaded = 0;
7068
+ for (const file of scan.files) {
7069
+ const source = sourceFor(file.path);
7070
+ const kind = source?.type ?? classifyKind(basename10(file.path));
7071
+ const entry = { path: file.path, type: kind };
7072
+ if (source)
7073
+ entry.assetId = source.id;
7074
+ if (isTextKind(kind)) {
7075
+ entry.text = readFileSync17(file.absPath, "utf8");
7076
+ } else if (!source || source.sha !== file.sha) {
7077
+ const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync17(file.absPath));
7078
+ entry.blobFileId = blob.id;
7079
+ uploaded++;
7080
+ }
7081
+ files.push(entry);
7082
+ }
7083
+ const folders = scan.dirs.map((path) => ({ path }));
7084
+ if (uploaded > 0)
7085
+ info(c.dim(`Uploaded ${uploaded} binary file${uploaded === 1 ? "" : "s"}.`));
7086
+ const res = await sync(apiUrl, token, manifest.uuid, { message, files, folders, publish });
7087
+ if (!res.created) {
7088
+ info(c.dim("Server reported no changes."));
7089
+ if (publish)
7090
+ reportPublish(res);
7091
+ return;
7092
+ }
7093
+ const shaByPath = new Map(scan.files.map((f2) => [f2.path, f2.sha]));
7094
+ const newFiles = {};
7095
+ for (const node of res.nodes) {
7096
+ newFiles[node.path] = { id: node.id, type: node.type, sha: shaByPath.get(node.path) ?? "" };
7097
+ }
7098
+ writeManifest(root, { ...manifest, baseHeadId: res.headId, files: newFiles });
7099
+ const counts = [
7100
+ diff2.added.length - diff2.moves.size && `${diff2.added.length - diff2.moves.size} added`,
7101
+ diff2.modified.length && `${diff2.modified.length} modified`,
7102
+ diff2.moves.size && `${diff2.moves.size} moved`,
7103
+ diff2.deleted.length - diff2.moves.size && `${diff2.deleted.length - diff2.moves.size} deleted`
7104
+ ].filter(Boolean).join(", ");
7105
+ success(`Pushed checkpoint${res.seq ? ` #${res.seq}` : ""}${counts ? ` (${counts})` : ""}: ${message}`);
7106
+ if (publish)
7107
+ reportPublish(res);
7108
+ };
7109
+ var serverPathDrift = (project, manifest) => {
7110
+ const pathById = buildPaths(project.assets);
7111
+ const serverPaths = new Set(project.assets.filter((a) => a.type !== "folder").map((a) => pathById.get(a.id)));
7112
+ const manifestPaths = new Set(Object.keys(manifest.files));
7113
+ const added = [...serverPaths].filter((p) => !manifestPaths.has(p));
7114
+ const removed = [...manifestPaths].filter((p) => !serverPaths.has(p));
7115
+ const describe = (label, paths) => `${paths.length} ${label} on the server: ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", …" : ""}`;
7116
+ const parts = [
7117
+ added.length ? describe("added", added) : "",
7118
+ removed.length ? describe("removed", removed) : ""
7119
+ ].filter(Boolean);
7120
+ return parts.length ? parts.join("; ") : null;
7121
+ };
7122
+ var reportPublish = (res) => {
7123
+ if (res.published)
7124
+ success("Published — the project is now live.");
7125
+ else
7126
+ warn(`Push landed, but publish failed: ${res.publishError ?? "unknown error"}`);
7127
+ };
7128
+
7129
+ // src/commands/link.ts
7130
+ var kindOf2 = (type) => type === "file" ? "resource" : type;
7131
+ var manifestFromServer = (project) => {
7132
+ const pathById = buildPaths(project.assets);
7133
+ const files = {};
7134
+ for (const asset of project.assets) {
7135
+ if (asset.type === "folder")
7136
+ continue;
7137
+ files[pathById.get(asset.id)] = {
7138
+ id: asset.id,
7139
+ type: kindOf2(asset.type),
7140
+ sha: asset.type === "file" ? "" : sha256(asset.text ?? "")
7141
+ };
7142
+ }
7143
+ return files;
7144
+ };
7145
+ var confirmAdoption = async (root, project, files, args) => {
7146
+ const count = Object.keys(files).length;
7147
+ if (count === 0) {
7148
+ info(c.dim(`Project ${c.bold(project.name)} is empty on the server — nothing to overwrite.`));
7149
+ return true;
7150
+ }
7151
+ const scan = scanProject(root, { files });
7152
+ const diff2 = computeServerDiff(project, scan);
7153
+ if (diff2.length === 0) {
7154
+ info(c.dim(`Project ${c.bold(project.name)} (${count} file(s)) already matches this folder.`));
7155
+ return true;
7156
+ }
7157
+ warn(`${c.bold(project.name)} already has ${count} file(s). Your next push would change the server:`);
7158
+ const label = { added: c.green(" create "), removed: c.red(" delete "), modified: c.yellow(" update ") };
7159
+ for (const e of diff2)
7160
+ log(label[e.status] + e.path + (e.binary ? c.dim(" (binary)") : ""));
7161
+ log("");
7162
+ note("`lecodes pull` after linking takes the server's version instead.");
7163
+ if (flagBool(args, "y", "yes") || flagBool(args, "force") || !process.stdin.isTTY)
7164
+ return true;
7165
+ return confirm("Link this folder to that project?", false);
7166
+ };
7167
+ var link = async (args) => {
7168
+ const config = loadConfig();
7169
+ const apiUrl = requireApiUrl(config);
7170
+ const token = requireToken(config);
7171
+ const dirArg = args._[0];
7172
+ const root = dirArg ? resolve6(dirArg) : process.cwd();
7173
+ if (!existsSync17(root))
7174
+ throw new CliError(`No such directory: ${root}`);
7175
+ if (existsSync17(join19(root, LECODES_DIR, "manifest.json")) && !flagBool(args, "force")) {
7176
+ throw new CliError("This folder is already linked (.lecodes/manifest.json exists) — use `lecodes push`, or `lecodes link --force` to re-link it to another project.");
7177
+ }
7178
+ let enclosing = null;
7179
+ try {
7180
+ enclosing = findProjectRoot(root);
7181
+ } catch {}
7182
+ if (enclosing && enclosing !== root) {
7183
+ throw new CliError(`This folder is inside the linked project at ${enclosing} — push from there instead.`);
7184
+ }
7185
+ const projectRef = flagStr(args, "project");
7186
+ let uuid;
7187
+ let name;
7188
+ let files;
7189
+ if (projectRef) {
7190
+ uuid = parseUuid(projectRef);
7191
+ const project = await getProject(apiUrl, token, uuid);
7192
+ if (project.role === "viewer") {
7193
+ throw new CliError(`You have view-only access to ${project.name} — linking would give you a checkout you can't push.`);
7194
+ }
7195
+ files = manifestFromServer(project);
7196
+ if (!await confirmAdoption(root, project, files, args)) {
7197
+ info(c.dim("Aborted — nothing was linked."));
7198
+ return;
7199
+ }
7200
+ name = project.name;
7201
+ } else {
7202
+ name = flagStr(args, "name") ?? basename11(root);
7203
+ info(c.dim(`Creating project ${c.bold(name)}…`));
7204
+ uuid = (await createProject(apiUrl, token, name, flagStr(args, "team"))).uuid;
7205
+ files = {};
7206
+ }
7207
+ const commits = await getCommits(apiUrl, token, uuid);
7208
+ writeManifest(root, { uuid, apiUrl, name, baseHeadId: commits.headId, files });
7209
+ log(`${c.green("linked")} ${c.bold(name)} ${c.dim(uuid)}`);
7210
+ if (!flagBool(args, "no-types")) {
7211
+ await materializeTypes(root, apiUrl);
7212
+ writeTsconfig(root);
7213
+ log(`${c.green("written")} ${LECODES_DIR}/types/ + tsconfig.json ${c.dim("(from the server)")}`);
7214
+ }
7215
+ writeDefaultIgnore(root);
7216
+ if (flagBool(args, "push")) {
7217
+ log("");
7218
+ if (root !== process.cwd())
7219
+ process.chdir(root);
7220
+ await push(args);
7221
+ return;
7222
+ }
7223
+ const pending = scanProject(root, { files }).files.filter((f2) => {
7224
+ const known = files[f2.path];
7225
+ return !known || known.sha !== f2.sha;
7226
+ }).length;
7227
+ log("");
7228
+ success(`Connected to ${c.bold(name)}.`);
7229
+ const cd = dirArg ? `cd ${dirArg} && ` : "";
7230
+ log(` ${c.bold(cd + "lecodes status")} ${pending} file(s) would go up`);
7231
+ log(` ${c.bold(cd + 'lecodes push -m "initial"')} push them as the first checkpoint`);
7232
+ log(` ${c.dim("(add --publish to deploy it live in the same step)")}`);
7233
+ };
7234
+
6766
7235
  // src/commands/login.ts
6767
7236
  var login = async (args) => {
6768
7237
  const config = loadConfig();
@@ -6775,8 +7244,8 @@ var login = async (args) => {
6775
7244
  };
6776
7245
 
6777
7246
  // src/commands/render.ts
6778
- import { mkdirSync as mkdirSync13, readFileSync as readFileSync17, writeFileSync as writeFileSync14 } from "node:fs";
6779
- import { dirname as dirname11, resolve as resolve6 } from "node:path";
7247
+ import { mkdirSync as mkdirSync13, readFileSync as readFileSync18, writeFileSync as writeFileSync14 } from "node:fs";
7248
+ import { dirname as dirname11, resolve as resolve7 } from "node:path";
6780
7249
 
6781
7250
  // src/compile/headlessBundle.ts
6782
7251
  var LOCAL_ASSET_SCHEME2 = "lecodesfile://";
@@ -6877,7 +7346,7 @@ var requireRenderer = async () => {
6877
7346
  };
6878
7347
  var fileScenarioIO = (dir) => ({
6879
7348
  write: (relPath, data) => {
6880
- const abs = resolve6(dir, relPath);
7349
+ const abs = resolve7(dir, relPath);
6881
7350
  mkdirSync13(dirname11(abs), { recursive: true });
6882
7351
  writeFileSync14(abs, data);
6883
7352
  }
@@ -6887,9 +7356,9 @@ var resolveFixtures = (value, baseDir) => {
6887
7356
  return;
6888
7357
  let map = value;
6889
7358
  if (typeof value === "string") {
6890
- const abs = resolve6(baseDir, value);
7359
+ const abs = resolve7(baseDir, value);
6891
7360
  try {
6892
- map = JSON.parse(readFileSync17(abs, "utf8"));
7361
+ map = JSON.parse(readFileSync18(abs, "utf8"));
6893
7362
  } catch (e) {
6894
7363
  throw new CliError(`Can't read fixtures "${value}": ${e instanceof Error ? e.message : String(e)}`);
6895
7364
  }
@@ -6902,7 +7371,7 @@ var resolveFixtures = (value, baseDir) => {
6902
7371
  var readScenarioFile = (path) => {
6903
7372
  let raw;
6904
7373
  try {
6905
- raw = JSON.parse(readFileSync17(resolve6(path), "utf8"));
7374
+ raw = JSON.parse(readFileSync18(resolve7(path), "utf8"));
6906
7375
  } catch (e) {
6907
7376
  throw new CliError(`Can't read scenario "${path}": ${e instanceof Error ? e.message : String(e)}`);
6908
7377
  }
@@ -6932,7 +7401,7 @@ var render = async (args) => {
6932
7401
  const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined);
6933
7402
  const timeStr = flagStr(args, "time");
6934
7403
  const onConsole = flagBool(args, "logs") ? (level, text2) => logErr(`${c.dim(`[${level}]`)} ${text2}`) : undefined;
6935
- const envelopeFixtures = resolveFixtures(scenario?.fixtures, scriptFlag !== undefined ? dirname11(resolve6(scriptFlag)) : ".");
7404
+ const envelopeFixtures = resolveFixtures(scenario?.fixtures, scriptFlag !== undefined ? dirname11(resolve7(scriptFlag)) : ".");
6936
7405
  const flagFixtures = resolveFixtures(flagStr(args, "fixtures"), ".");
6937
7406
  const fixtures = envelopeFixtures || flagFixtures ? { ...envelopeFixtures, ...flagFixtures } : undefined;
6938
7407
  if (fixtures) {
@@ -6971,7 +7440,7 @@ var render = async (args) => {
6971
7440
  ${c.red(st.error ?? "failed")}`);
6972
7441
  }
6973
7442
  if (!result2.ok && result2.failureArtifacts.length > 0) {
6974
- logErr(c.dim(` failing frame: ${result2.failureArtifacts.map((p) => resolve6(outDir, p)).join(", ")}`));
7443
+ logErr(c.dim(` failing frame: ${result2.failureArtifacts.map((p) => resolve7(outDir, p)).join(", ")}`));
6975
7444
  }
6976
7445
  logErr(result2.ok ? c.green(`✓ ${result2.steps.length}/${steps.length} steps passed`) : c.red(`✗ failed at step ${result2.steps.length} of ${steps.length}`));
6977
7446
  return done((cb) => cb(), result2.ok ? 0 : 1);
@@ -6993,7 +7462,7 @@ var render = async (args) => {
6993
7462
  if (isNullSink(pngFlag)) {
6994
7463
  note(`Discarded PNG (${pngFlag})`);
6995
7464
  } else {
6996
- writeFileSync14(resolve6(pngFlag), png);
7465
+ writeFileSync14(resolve7(pngFlag), png);
6997
7466
  note(`Wrote ${pngFlag} (${png.length} bytes, ${width}x${height})`);
6998
7467
  }
6999
7468
  return done((cb) => cb());
@@ -7008,7 +7477,7 @@ var render = async (args) => {
7008
7477
  return done((cb) => cb());
7009
7478
  }
7010
7479
  if (out) {
7011
- writeFileSync14(resolve6(out), result.text);
7480
+ writeFileSync14(resolve7(out), result.text);
7012
7481
  note(`Wrote ${out} (${result.text.length} bytes)`);
7013
7482
  return done((cb) => cb());
7014
7483
  }
@@ -7019,8 +7488,8 @@ var render = async (args) => {
7019
7488
  };
7020
7489
 
7021
7490
  // src/commands/pull.ts
7022
- import { existsSync as existsSync17, mkdirSync as mkdirSync14, rmSync as rmSync2, writeFileSync as writeFileSync15 } from "node:fs";
7023
- import { dirname as dirname12, join as join19 } from "node:path";
7491
+ import { existsSync as existsSync18, mkdirSync as mkdirSync14, rmSync as rmSync3, writeFileSync as writeFileSync15 } from "node:fs";
7492
+ import { dirname as dirname12, join as join20 } from "node:path";
7024
7493
  var pull = async (args) => {
7025
7494
  const root = findProjectRoot(process.cwd());
7026
7495
  const manifest = readManifest(root);
@@ -7040,9 +7509,9 @@ var pull = async (args) => {
7040
7509
  const remotePaths = new Set([...buildPaths(project.assets).entries()].filter(([id]) => project.assets.find((a) => a.id === id)?.type !== "folder").map(([, path]) => path));
7041
7510
  for (const path of Object.keys(manifest.files)) {
7042
7511
  if (!remotePaths.has(path)) {
7043
- const abs = join19(root, ...path.replace(/^\//, "").split("/"));
7044
- if (existsSync17(abs))
7045
- rmSync2(abs);
7512
+ const abs = join20(root, ...path.replace(/^\//, "").split("/"));
7513
+ if (existsSync18(abs))
7514
+ rmSync3(abs);
7046
7515
  }
7047
7516
  }
7048
7517
  const files = await materializeAssets(root, project, apiUrl, token, progress);
@@ -7055,7 +7524,7 @@ var pull = async (args) => {
7055
7524
  writeManifest(root, { ...manifest, name: project.name, baseHeadId: commits.headId, files });
7056
7525
  success(`Pulled latest into ${c.bold(manifest.name)}.`);
7057
7526
  };
7058
- var kindOf2 = (type) => type === "file" ? "resource" : type;
7527
+ var kindOf3 = (type) => type === "file" ? "resource" : type;
7059
7528
  var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
7060
7529
  const pathById = buildPaths(project.assets);
7061
7530
  const byPath = new Map;
@@ -7079,7 +7548,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
7079
7548
  const files = { ...manifest.files };
7080
7549
  for (const asset of targets) {
7081
7550
  const key = pathById.get(asset.id);
7082
- const abs = join19(root, ...key.replace(/^\//, "").split("/"));
7551
+ const abs = join20(root, ...key.replace(/^\//, "").split("/"));
7083
7552
  mkdirSync14(dirname12(abs), { recursive: true });
7084
7553
  let sha;
7085
7554
  if (asset.type === "file") {
@@ -7093,7 +7562,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
7093
7562
  writeFileSync15(abs, text);
7094
7563
  sha = sha256(text);
7095
7564
  }
7096
- files[key] = { id: asset.id, type: kindOf2(asset.type), sha };
7565
+ files[key] = { id: asset.id, type: kindOf3(asset.type), sha };
7097
7566
  info(c.green(` pulled ${key}`));
7098
7567
  }
7099
7568
  writeManifest(root, { ...manifest, files });
@@ -7101,137 +7570,22 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
7101
7570
  info(c.dim("Local edits to other files are untouched. Run `lecodes diff` to review, then `lecodes push --force`."));
7102
7571
  };
7103
7572
 
7104
- // src/commands/push.ts
7105
- import { basename as basename10 } from "node:path";
7106
- import { readFileSync as readFileSync18 } from "node:fs";
7107
- var push = async (args) => {
7108
- const root = findProjectRoot(process.cwd());
7109
- const manifest = readManifest(root);
7110
- const config = loadConfig();
7111
- const apiUrl = requireApiUrl(config);
7112
- const token = requireToken(config);
7113
- const publish = flagBool(args, "publish");
7114
- const scan = scanProject(root, manifest);
7115
- const diff2 = diffLocal(manifest, scan);
7116
- if (!hasChanges(diff2)) {
7117
- if (publish) {
7118
- await publishProject(apiUrl, token, manifest.uuid);
7119
- success("No file changes — published the current project state.");
7120
- } else {
7121
- info(c.dim("Working tree clean — nothing to push."));
7122
- }
7123
- return;
7124
- }
7125
- const force = flagBool(args, "force");
7126
- if (!force && manifest.baseHeadId !== null) {
7127
- const commits = await getCommits(apiUrl, token, manifest.uuid);
7128
- if (commits.headId !== manifest.baseHeadId) {
7129
- 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.");
7130
- }
7131
- if (commits.dirty) {
7132
- const drift = serverPathDrift(await getProject(apiUrl, token, manifest.uuid), manifest);
7133
- if (drift) {
7134
- 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.`);
7135
- }
7136
- }
7137
- }
7138
- if (force) {
7139
- const project = await getProject(apiUrl, token, manifest.uuid);
7140
- const serverDiff = computeServerDiff(project, scan);
7141
- if (serverDiff.length) {
7142
- info(c.yellow(`--force will overwrite the server. ${serverDiff.length} file${serverDiff.length === 1 ? "" : "s"} change:`));
7143
- const label = { added: c.green(" create "), removed: c.red(" delete "), modified: c.yellow(" update ") };
7144
- for (const e of serverDiff)
7145
- log(label[e.status] + e.path + (e.binary ? c.dim(" (binary)") : ""));
7146
- if (process.stdin.isTTY && !flagBool(args, "y", "yes")) {
7147
- if (!await confirm("Overwrite the server with your local copy?", false)) {
7148
- info(c.dim("Aborted."));
7149
- return;
7150
- }
7151
- }
7152
- }
7153
- }
7154
- const message = (flagStr(args, "m", "message") ?? await prompt("Checkpoint message")) || "Update from lecodes CLI";
7155
- const sourceFor = (path) => manifest.files[path] ?? (diff2.moves.has(path) ? manifest.files[diff2.moves.get(path)] : undefined);
7156
- const files = [];
7157
- let uploaded = 0;
7158
- for (const file of scan.files) {
7159
- const source = sourceFor(file.path);
7160
- const kind = source?.type ?? classifyKind(basename10(file.path));
7161
- const entry = { path: file.path, type: kind };
7162
- if (source)
7163
- entry.assetId = source.id;
7164
- if (isTextKind(kind)) {
7165
- entry.text = readFileSync18(file.absPath, "utf8");
7166
- } else if (!source || source.sha !== file.sha) {
7167
- const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync18(file.absPath));
7168
- entry.blobFileId = blob.id;
7169
- uploaded++;
7170
- }
7171
- files.push(entry);
7172
- }
7173
- const folders = scan.dirs.map((path) => ({ path }));
7174
- if (uploaded > 0)
7175
- info(c.dim(`Uploaded ${uploaded} binary file${uploaded === 1 ? "" : "s"}.`));
7176
- const res = await sync(apiUrl, token, manifest.uuid, { message, files, folders, publish });
7177
- if (!res.created) {
7178
- info(c.dim("Server reported no changes."));
7179
- if (publish)
7180
- reportPublish(res);
7181
- return;
7182
- }
7183
- const shaByPath = new Map(scan.files.map((f2) => [f2.path, f2.sha]));
7184
- const newFiles = {};
7185
- for (const node of res.nodes) {
7186
- newFiles[node.path] = { id: node.id, type: node.type, sha: shaByPath.get(node.path) ?? "" };
7187
- }
7188
- writeManifest(root, { ...manifest, baseHeadId: res.headId, files: newFiles });
7189
- const counts = [
7190
- diff2.added.length - diff2.moves.size && `${diff2.added.length - diff2.moves.size} added`,
7191
- diff2.modified.length && `${diff2.modified.length} modified`,
7192
- diff2.moves.size && `${diff2.moves.size} moved`,
7193
- diff2.deleted.length - diff2.moves.size && `${diff2.deleted.length - diff2.moves.size} deleted`
7194
- ].filter(Boolean).join(", ");
7195
- success(`Pushed checkpoint${res.seq ? ` #${res.seq}` : ""}${counts ? ` (${counts})` : ""}: ${message}`);
7196
- if (publish)
7197
- reportPublish(res);
7198
- };
7199
- var serverPathDrift = (project, manifest) => {
7200
- const pathById = buildPaths(project.assets);
7201
- const serverPaths = new Set(project.assets.filter((a) => a.type !== "folder").map((a) => pathById.get(a.id)));
7202
- const manifestPaths = new Set(Object.keys(manifest.files));
7203
- const added = [...serverPaths].filter((p) => !manifestPaths.has(p));
7204
- const removed = [...manifestPaths].filter((p) => !serverPaths.has(p));
7205
- const describe = (label, paths) => `${paths.length} ${label} on the server: ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", …" : ""}`;
7206
- const parts = [
7207
- added.length ? describe("added", added) : "",
7208
- removed.length ? describe("removed", removed) : ""
7209
- ].filter(Boolean);
7210
- return parts.length ? parts.join("; ") : null;
7211
- };
7212
- var reportPublish = (res) => {
7213
- if (res.published)
7214
- success("Published — the project is now live.");
7215
- else
7216
- warn(`Push landed, but publish failed: ${res.publishError ?? "unknown error"}`);
7217
- };
7218
-
7219
7573
  // src/commands/scene.ts
7220
- import { existsSync as existsSync19, readFileSync as readFileSync20, readdirSync as readdirSync8, statSync as statSync4, writeFileSync as writeFileSync16 } from "node:fs";
7221
- import { basename as basename11, dirname as dirname13, join as join21, resolve as resolve7 } from "node:path";
7574
+ import { existsSync as existsSync20, readFileSync as readFileSync20, readdirSync as readdirSync9, statSync as statSync5, writeFileSync as writeFileSync16 } from "node:fs";
7575
+ import { basename as basename12, dirname as dirname13, join as join22, resolve as resolve8 } from "node:path";
7222
7576
 
7223
7577
  // src/compile/sceneCompile.ts
7224
- import { existsSync as existsSync18, readFileSync as readFileSync19 } from "node:fs";
7225
- import { join as join20 } from "node:path";
7578
+ import { existsSync as existsSync19, readFileSync as readFileSync19 } from "node:fs";
7579
+ import { join as join21 } from "node:path";
7226
7580
  var TEXT_EXT = /\.(ts|js|json|svg|txt|md)$/i;
7227
7581
  var rel = (key) => key.replace(/^\//, "");
7228
7582
  var resolveHarness = (loadSceneHarness2) => {
7229
- const inRepo = join20(distRoot, "..", "projects", "src", "scene-editor");
7583
+ const inRepo = join21(distRoot, "..", "projects", "src", "scene-editor");
7230
7584
  try {
7231
- return loadSceneHarness2(existsSync18(inRepo) ? inRepo : undefined);
7585
+ return loadSceneHarness2(existsSync19(inRepo) ? inRepo : undefined);
7232
7586
  } catch {
7233
- const vendored = join20(distRoot, "runtime", "scene-harness.json");
7234
- if (existsSync18(vendored))
7587
+ const vendored = join21(distRoot, "runtime", "scene-harness.json");
7588
+ if (existsSync19(vendored))
7235
7589
  return JSON.parse(readFileSync19(vendored, "utf8"));
7236
7590
  throw new CliError("scene-editor harness sources not found — reinstall the CLI or set LECODES_SCENE_HARNESS_DIR.");
7237
7591
  }
@@ -7284,22 +7638,22 @@ var getContext2 = (args) => {
7284
7638
  manifest = null;
7285
7639
  const pathArg = args._[0];
7286
7640
  if (pathArg) {
7287
- const abs = resolve7(process.cwd(), pathArg);
7288
- if (existsSync19(abs) && statSync4(abs).isDirectory())
7641
+ const abs = resolve8(process.cwd(), pathArg);
7642
+ if (existsSync20(abs) && statSync5(abs).isDirectory())
7289
7643
  root = abs;
7290
- else if (abs.endsWith(".scene.ts") && existsSync19(abs))
7644
+ else if (abs.endsWith(".scene.ts") && existsSync20(abs))
7291
7645
  root = dirname13(abs);
7292
7646
  else
7293
7647
  throw new CliError(`No such folder or .scene.ts: ${pathArg}`);
7294
7648
  }
7295
7649
  }
7296
- return { root, manifest, name: manifest?.name ?? basename11(root) };
7650
+ return { root, manifest, name: manifest?.name ?? basename12(root) };
7297
7651
  };
7298
7652
  var writeMcpConfig2 = (root, port) => {
7299
- const path = join21(root, ".mcp.json");
7653
+ const path = join22(root, ".mcp.json");
7300
7654
  const entry = { type: "http", url: `http://localhost:${port}/mcp` };
7301
7655
  let config = {};
7302
- if (existsSync19(path)) {
7656
+ if (existsSync20(path)) {
7303
7657
  try {
7304
7658
  config = JSON.parse(readFileSync20(path, "utf8"));
7305
7659
  } catch {
@@ -7320,11 +7674,11 @@ var writeMcpConfig2 = (root, port) => {
7320
7674
  };
7321
7675
  var countScenes = (dir) => {
7322
7676
  let n = 0;
7323
- for (const name of readdirSync8(dir)) {
7677
+ for (const name of readdirSync9(dir)) {
7324
7678
  if (name === "node_modules" || name.startsWith("."))
7325
7679
  continue;
7326
- const full = join21(dir, name);
7327
- if (statSync4(full).isDirectory())
7680
+ const full = join22(dir, name);
7681
+ if (statSync5(full).isDirectory())
7328
7682
  n += countScenes(full);
7329
7683
  else if (name.endsWith(".scene.ts"))
7330
7684
  n++;
@@ -7394,8 +7748,8 @@ var status = async (_args) => {
7394
7748
  };
7395
7749
 
7396
7750
  // src/commands/test.ts
7397
- import { existsSync as existsSync20, readFileSync as readFileSync21, readdirSync as readdirSync9 } from "node:fs";
7398
- import { dirname as dirname14, resolve as resolve8 } from "node:path";
7751
+ import { existsSync as existsSync21, readFileSync as readFileSync21, readdirSync as readdirSync10 } from "node:fs";
7752
+ import { dirname as dirname14, resolve as resolve9 } from "node:path";
7399
7753
  var deriveDesignFlows = (meta, entryScreen) => {
7400
7754
  const edges = meta.edges ?? [];
7401
7755
  const isState = (id) => id.includes("@");
@@ -7481,34 +7835,34 @@ var readFlowFile = (abs, displayName) => {
7481
7835
  var test = async (args) => {
7482
7836
  const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args);
7483
7837
  const renderer = await requireRenderer();
7484
- const testsDir = resolve8(root, flagStr(args, "dir") ?? "tests");
7485
- const designMetaPath = resolve8(root, flagStr(args, "design-dir") ?? "design", "meta.json");
7838
+ const testsDir = resolve9(root, flagStr(args, "dir") ?? "tests");
7839
+ const designMetaPath = resolve9(root, flagStr(args, "design-dir") ?? "design", "meta.json");
7486
7840
  const asJson = flagBool(args, "json");
7487
7841
  const out = asJson ? logErr : log;
7488
7842
  const cases = [];
7489
7843
  if (args._.length > 0) {
7490
7844
  for (const given of args._) {
7491
- const candidates = [resolve8(given), resolve8(testsDir, given), resolve8(testsDir, `${given}.flow.json`)];
7492
- const abs = candidates.find((p) => existsSync20(p));
7845
+ const candidates = [resolve9(given), resolve9(testsDir, given), resolve9(testsDir, `${given}.flow.json`)];
7846
+ const abs = candidates.find((p) => existsSync21(p));
7493
7847
  if (!abs)
7494
7848
  throw new CliError(`No such test: "${given}" (looked in ${testsDir}).`);
7495
7849
  cases.push(readFlowFile(abs, given));
7496
7850
  }
7497
- } else if (existsSync20(testsDir)) {
7498
- for (const f2 of readdirSync9(testsDir).filter((f3) => f3.endsWith(".flow.json")).sort()) {
7499
- cases.push(readFlowFile(resolve8(testsDir, f2), f2));
7851
+ } else if (existsSync21(testsDir)) {
7852
+ for (const f2 of readdirSync10(testsDir).filter((f3) => f3.endsWith(".flow.json")).sort()) {
7853
+ cases.push(readFlowFile(resolve9(testsDir, f2), f2));
7500
7854
  }
7501
7855
  }
7502
7856
  const fixturesFlag = flagStr(args, "fixtures");
7503
- const defaultPack = resolve8(testsDir, "fixtures.json");
7504
- const flowFixtures = fixturesFlag !== undefined ? resolveFixtures(fixturesFlag, ".") : existsSync20(defaultPack) ? resolveFixtures("fixtures.json", testsDir) : undefined;
7857
+ const defaultPack = resolve9(testsDir, "fixtures.json");
7858
+ const flowFixtures = fixturesFlag !== undefined ? resolveFixtures(fixturesFlag, ".") : existsSync21(defaultPack) ? resolveFixtures("fixtures.json", testsDir) : undefined;
7505
7859
  const flowsOnly = flagBool(args, "flows");
7506
7860
  const wantFlows = !flagBool(args, "no-flows") && args._.length === 0;
7507
7861
  if (flowsOnly)
7508
7862
  cases.length = 0;
7509
7863
  let skipped = [];
7510
7864
  let entryScreen = null;
7511
- if ((wantFlows || flowsOnly) && existsSync20(designMetaPath)) {
7865
+ if ((wantFlows || flowsOnly) && existsSync21(designMetaPath)) {
7512
7866
  let meta;
7513
7867
  try {
7514
7868
  meta = JSON.parse(readFileSync21(designMetaPath, "utf8"));
@@ -7543,7 +7897,7 @@ var test = async (args) => {
7543
7897
  }, timeoutMs);
7544
7898
  killer.unref?.();
7545
7899
  const onConsole = flagBool(args, "logs") ? (level, text) => logErr(`${c.dim(`[${level}]`)} ${text}`) : undefined;
7546
- const artifactsRoot = resolve8(root, flagStr(args, "artifacts") ?? resolve8(testsDir, ".artifacts"));
7900
+ const artifactsRoot = resolve9(root, flagStr(args, "artifacts") ?? resolve9(testsDir, ".artifacts"));
7547
7901
  const results = [];
7548
7902
  for (const kase of cases) {
7549
7903
  const flagSettle = flagStr(args, "settle");
@@ -7561,14 +7915,14 @@ var test = async (args) => {
7561
7915
  onConsole
7562
7916
  });
7563
7917
  try {
7564
- const io = fileScenarioIO(resolve8(artifactsRoot, slug3(kase.name)));
7918
+ const io = fileScenarioIO(resolve9(artifactsRoot, slug3(kase.name)));
7565
7919
  const outcome = await renderer.runScenario(session, steps, io);
7566
7920
  result = {
7567
7921
  name: kase.name,
7568
7922
  kind: kase.kind,
7569
7923
  ok: outcome.ok,
7570
7924
  steps: outcome.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error })),
7571
- artifacts: outcome.failureArtifacts.map((p) => resolve8(artifactsRoot, slug3(kase.name), p))
7925
+ artifacts: outcome.failureArtifacts.map((p) => resolve9(artifactsRoot, slug3(kase.name), p))
7572
7926
  };
7573
7927
  } finally {
7574
7928
  session.dispose();
@@ -7624,23 +7978,86 @@ var test = async (args) => {
7624
7978
  process.exit(failed > 0 ? 1 : 0);
7625
7979
  };
7626
7980
 
7981
+ // src/commands/types.ts
7982
+ import { resolve as resolve10 } from "node:path";
7983
+ var label = (bundle) => `${Object.keys(bundle).length} file(s)`;
7984
+ var types = async (args) => {
7985
+ const dirArg = args._[0];
7986
+ const root = dirArg ? resolve10(dirArg) : (() => {
7987
+ try {
7988
+ return findProjectRoot(process.cwd());
7989
+ } catch {
7990
+ return process.cwd();
7991
+ }
7992
+ })();
7993
+ const useLocal = flagBool(args, "local");
7994
+ const checkOnly = flagBool(args, "check");
7995
+ const local = readLocalTypeBundle();
7996
+ let bundle = null;
7997
+ let source = "";
7998
+ if (useLocal) {
7999
+ if (!local)
8000
+ throw new CliError("This CLI ships no vendored type bundle — drop --local to fetch them from the server.");
8001
+ bundle = local;
8002
+ source = "the CLI's bundled SDK";
8003
+ } else {
8004
+ const apiUrl = normalizeApiUrl(flagStr(args, "api") ?? loadConfig().apiUrl ?? DEFAULT_API);
8005
+ try {
8006
+ bundle = await getTypes(apiUrl);
8007
+ source = apiUrl;
8008
+ } catch (e) {
8009
+ if (!local || checkOnly)
8010
+ throw e;
8011
+ warn(`${e.message}`);
8012
+ bundle = local;
8013
+ source = "the CLI's bundled SDK (server unreachable)";
8014
+ }
8015
+ }
8016
+ log(`${c.green("source")} ${source} — ${label(bundle)}`);
8017
+ const onDisk = readProjectTypeBundle(root);
8018
+ const same = bundleHash(onDisk) === bundleHash(bundle);
8019
+ if (checkOnly) {
8020
+ if (Object.keys(onDisk).length === 0)
8021
+ throw new CliError(`No types in ${LECODES_DIR}/types/ — run \`lecodes types\`.`);
8022
+ if (!same)
8023
+ throw new CliError(`Types are out of date (${label(onDisk)} on disk) — run \`lecodes types\`.`);
8024
+ success(`Types are up to date (${label(onDisk)}).`);
8025
+ return;
8026
+ }
8027
+ const stats = writeTypeBundle(root, bundle);
8028
+ writeTsconfig(root);
8029
+ const changes = [
8030
+ stats.added ? `${stats.added} added` : null,
8031
+ stats.updated ? `${stats.updated} updated` : null,
8032
+ stats.removed ? `${stats.removed} removed` : null
8033
+ ].filter(Boolean);
8034
+ log(`${c.green("written")} ${LECODES_DIR}/types/ + tsconfig.json ${c.dim(changes.length ? `(${changes.join(", ")})` : "(already current)")}`);
8035
+ if (!useLocal && local && bundleHash(local) !== bundleHash(bundle)) {
8036
+ warn("The server's SDK surface differs from the one this CLI compiles with — if something autocompletes but fails to compile, update the CLI (`npm i -g lecodes-cli@latest`) or re-run with --local.");
8037
+ }
8038
+ log("");
8039
+ success(stats.added + stats.updated + stats.removed > 0 ? "Types synced." : "Types already current.");
8040
+ if (!dirArg)
8041
+ note("Reload the TypeScript server in your editor to pick them up.");
8042
+ };
8043
+
7627
8044
  // src/index.ts
7628
- import { existsSync as existsSync22, readFileSync as readFileSync23, realpathSync as realpathSync2 } from "node:fs";
8045
+ import { existsSync as existsSync23, readFileSync as readFileSync23, realpathSync as realpathSync2 } from "node:fs";
7629
8046
  import { createRequire as createRequire4 } from "node:module";
7630
- import { delimiter as delimiter2, dirname as dirname16, join as join23 } from "node:path";
8047
+ import { delimiter as delimiter2, dirname as dirname16, join as join24 } from "node:path";
7631
8048
 
7632
8049
  // src/commands/install.ts
7633
8050
  import { execFileSync as execFileSync2 } from "node:child_process";
7634
- import { appendFileSync as appendFileSync2, chmodSync, cpSync as cpSync2, existsSync as existsSync21, mkdirSync as mkdirSync15, readFileSync as readFileSync22, realpathSync, rmSync as rmSync3, symlinkSync } from "node:fs";
8051
+ import { appendFileSync as appendFileSync2, chmodSync, cpSync as cpSync2, existsSync as existsSync22, mkdirSync as mkdirSync15, readFileSync as readFileSync22, realpathSync, rmSync as rmSync4, symlinkSync } from "node:fs";
7635
8052
  import { homedir as homedir3 } from "node:os";
7636
- import { basename as basename12, delimiter, dirname as dirname15, join as join22, resolve as resolve9, sep } from "node:path";
8053
+ import { basename as basename13, delimiter, dirname as dirname15, join as join23, resolve as resolve11, sep as sep2 } from "node:path";
7637
8054
  var isWin = process.platform === "win32";
7638
8055
  var HOME = homedir3();
7639
8056
  var safeRealpath = (p) => {
7640
8057
  try {
7641
8058
  return realpathSync(p);
7642
8059
  } catch {
7643
- return resolve9(p);
8060
+ return resolve11(p);
7644
8061
  }
7645
8062
  };
7646
8063
  var onPath = (dir) => {
@@ -7657,12 +8074,12 @@ var onPath = (dir) => {
7657
8074
  };
7658
8075
  var install = async (args) => {
7659
8076
  const bundle = dirname15(process.execPath);
7660
- const exeName = basename12(process.execPath);
8077
+ const exeName = basename13(process.execPath);
7661
8078
  const chiselName = isWin ? "chisel.exe" : "chisel";
7662
- if (!existsSync21(join22(bundle, "runtime")) || !existsSync21(join22(bundle, chiselName))) {
8079
+ if (!existsSync22(join23(bundle, "runtime")) || !existsSync22(join23(bundle, chiselName))) {
7663
8080
  throw new CliError("`lecodes install` only works from the packaged release build (the archive containing lecodes + " + "chisel + runtime/). You appear to be running from source or an npm install — install globally " + "with your package manager there instead.");
7664
8081
  }
7665
- const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync21(join22(bundle, f2)));
8082
+ const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync22(join23(bundle, f2)));
7666
8083
  if (isWin)
7667
8084
  return installWindows(args, bundle, companions);
7668
8085
  return installUnix(args, bundle, companions);
@@ -7672,9 +8089,9 @@ var copyBundle = (bundle, target, companions) => {
7672
8089
  if (safeRealpath(target) === safeRealpath(bundle))
7673
8090
  return;
7674
8091
  for (const f2 of companions) {
7675
- const dest = join22(target, f2);
7676
- rmSync3(dest, { recursive: true, force: true });
7677
- cpSync2(join22(bundle, f2), dest, { recursive: true });
8092
+ const dest = join23(target, f2);
8093
+ rmSync4(dest, { recursive: true, force: true });
8094
+ cpSync2(join23(bundle, f2), dest, { recursive: true });
7678
8095
  }
7679
8096
  };
7680
8097
  var permError = (e, dir, system) => {
@@ -7686,41 +8103,41 @@ var permError = (e, dir, system) => {
7686
8103
  };
7687
8104
  var installUnix = (args, bundle, companions) => {
7688
8105
  const system = flagBool(args, "system");
7689
- const libDir = resolve9(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join22(HOME, ".local", "lib", "lecodes-cli")));
7690
- const binDir = resolve9(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join22(HOME, ".local", "bin")));
7691
- const link = join22(binDir, "lecodes");
8106
+ const libDir = resolve11(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join23(HOME, ".local", "lib", "lecodes-cli")));
8107
+ const binDir = resolve11(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join23(HOME, ".local", "bin")));
8108
+ const link2 = join23(binDir, "lecodes");
7692
8109
  try {
7693
8110
  copyBundle(bundle, libDir, companions);
7694
- chmodSync(join22(libDir, "lecodes"), 493);
7695
- chmodSync(join22(libDir, "chisel"), 493);
8111
+ chmodSync(join23(libDir, "lecodes"), 493);
8112
+ chmodSync(join23(libDir, "chisel"), 493);
7696
8113
  } catch (e) {
7697
8114
  throw permError(e, libDir, system);
7698
8115
  }
7699
8116
  try {
7700
8117
  mkdirSync15(binDir, { recursive: true });
7701
- rmSync3(link, { force: true });
7702
- symlinkSync(join22(libDir, "lecodes"), link);
8118
+ rmSync4(link2, { force: true });
8119
+ symlinkSync(join23(libDir, "lecodes"), link2);
7703
8120
  } catch (e) {
7704
8121
  throw permError(e, binDir, system);
7705
8122
  }
7706
8123
  success(`Installed to ${c.bold(libDir)}`);
7707
- log(` ${c.dim(link + " → " + join22(libDir, "lecodes"))}`);
8124
+ log(` ${c.dim(link2 + " → " + join23(libDir, "lecodes"))}`);
7708
8125
  finishPathUnix(args, binDir);
7709
8126
  };
7710
8127
  var shellRc = () => {
7711
- const shell = basename12(process.env.SHELL ?? "");
8128
+ const shell = basename13(process.env.SHELL ?? "");
7712
8129
  if (shell === "zsh")
7713
- return { file: join22(HOME, ".zshrc"), name: "~/.zshrc" };
8130
+ return { file: join23(HOME, ".zshrc"), name: "~/.zshrc" };
7714
8131
  if (shell === "bash")
7715
- return { file: join22(HOME, ".bashrc"), name: "~/.bashrc" };
7716
- return { file: join22(HOME, ".profile"), name: "~/.profile" };
8132
+ return { file: join23(HOME, ".bashrc"), name: "~/.bashrc" };
8133
+ return { file: join23(HOME, ".profile"), name: "~/.profile" };
7717
8134
  };
7718
8135
  var PATH_MARKER = "# Added by `lecodes install`";
7719
8136
  var appendPathToShellRc = (binDir) => {
7720
8137
  const { file, name } = shellRc();
7721
8138
  const line = `export PATH="${binDir}:$PATH"`;
7722
8139
  try {
7723
- const existing = existsSync21(file) ? readFileSync22(file, "utf8") : "";
8140
+ const existing = existsSync22(file) ? readFileSync22(file, "utf8") : "";
7724
8141
  if (existing.includes(line))
7725
8142
  return name;
7726
8143
  appendFileSync2(file, `${existing && !existing.endsWith(`
@@ -7758,7 +8175,7 @@ var finishPathUnix = (args, binDir) => {
7758
8175
  log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`);
7759
8176
  };
7760
8177
  var installWindows = (args, bundle, companions) => {
7761
- const target = resolve9(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
8178
+ const target = resolve11(flagStr(args, "dir") ?? join23(process.env.LOCALAPPDATA ?? join23(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
7762
8179
  try {
7763
8180
  copyBundle(bundle, target, companions);
7764
8181
  } catch (e) {
@@ -7797,7 +8214,7 @@ var addToUserPathWindows = (dir) => {
7797
8214
  var STANDALONE = (() => {
7798
8215
  const dir = dirname16(process.execPath);
7799
8216
  const chisel = process.platform === "win32" ? "chisel.exe" : "chisel";
7800
- return existsSync22(join23(dir, "runtime")) && existsSync22(join23(dir, chisel));
8217
+ return existsSync23(join24(dir, "runtime")) && existsSync23(join24(dir, chisel));
7801
8218
  })();
7802
8219
  var INSTALLED = STANDALONE && (() => {
7803
8220
  const exe = process.platform === "win32" ? "lecodes.exe" : "lecodes";
@@ -7812,8 +8229,8 @@ var INSTALLED = STANDALONE && (() => {
7812
8229
  if (!d)
7813
8230
  return false;
7814
8231
  try {
7815
- const cand = join23(d, exe);
7816
- return existsSync22(cand) && realpathSync2(cand) === self;
8232
+ const cand = join24(d, exe);
8233
+ return existsSync23(cand) && realpathSync2(cand) === self;
7817
8234
  } catch {
7818
8235
  return false;
7819
8236
  }
@@ -7844,6 +8261,19 @@ ${c.bold("Commands:")}
7844
8261
  --team <uuid> create it inside a team
7845
8262
  clone <uuid|url> [dir] Download a project's files + types into a new folder
7846
8263
  --no-types skip the .d.ts / tsconfig generation
8264
+ link [dir] Connect an existing local folder (e.g. one made by 'lecodes
8265
+ init') to the platform — the reverse of clone. Creates a
8266
+ project on the server, writes .lecodes/manifest.json and
8267
+ refreshes the types; nothing is uploaded until you push
8268
+ --name <s> project name (default: folder name)
8269
+ --team <uuid> create it inside a team
8270
+ --project <uuid|url> adopt an EXISTING project instead —
8271
+ prints what your next push would change
8272
+ on its server tree, and confirms first
8273
+ --push [-m <msg>] push right after linking (--publish to
8274
+ deploy it live in the same step)
8275
+ --force re-link a folder that's already linked
8276
+ --no-types skip the .d.ts / tsconfig refresh
7847
8277
  status Show local changes vs the last sync
7848
8278
  diff [path…] Show a unified diff of local vs the current server state
7849
8279
  --name-only list changed paths + status only
@@ -7907,6 +8337,13 @@ ${c.bold("Commands:")}
7907
8337
  --artifacts <dir> failure-artifact folder override
7908
8338
  (+ the render timing flags: --width/--height, --settle,
7909
8339
  --device, --safe-area, --wait-network, --logs, --entry)
8340
+ types [dir] Refresh the IDE types (.lecodes/types/ + tsconfig.json) — by
8341
+ default from the server, so a project scaffolded offline picks
8342
+ up the SDK the platform actually runs
8343
+ --local use the SDK bundled in this CLI instead
8344
+ --check report drift only, don't write (non-zero exit)
8345
+ --api <url> server to fetch from (default: the logged-in
8346
+ one, else https://le.codes)
7910
8347
  design [serve] LeCodes Design — a live canvas of screens for AI-driven prototyping
7911
8348
  (needs the optional 'lecodes-design' package; works inside a
7912
8349
  cloned project or standalone in any folder)
@@ -7933,6 +8370,9 @@ ${c.bold("Commands:")}
7933
8370
  --variant <v> SDK variant: auto (default — sync picks the
7934
8371
  smallest of core/2d/3d/full covering what
7935
8372
  the bundle uses), or one of those to pin
8373
+ --sdk <v> native SDK release (default: the newest one,
8374
+ pinned in app.json ios.sdk); 'latest' floats
8375
+ it — every sync re-resolves
7936
8376
  app sync Make the shell match the project: compile the bundle + embed
7937
8377
  its assets into ios/App/Resources/ (offline by default, also
7938
8378
  for server projects; app.json "resources": {"remote": [globs]}
@@ -7941,6 +8381,13 @@ ${c.bold("Commands:")}
7941
8381
  plugins (pinned in plugins.lock.json), regenerate the
7942
8382
  CLI-owned shell files. app.json "update": true (or
7943
8383
  {"url": …}) enables native OTA JS updates at launch
8384
+ app update Move the shell's pins forward — app.json ios.sdk to the newest
8385
+ lecodes-ios-sdk release, plugins.lock.json to each plugin's
8386
+ newest tag — then re-sync
8387
+ --sdk <v|latest> pin that release instead of the newest
8388
+ --check report available updates, write nothing
8389
+ --no-plugins only move the SDK pin
8390
+ --no-sync update the pins without re-syncing
7944
8391
  app plugin <list|add|remove> Manage native plugins in app.json (then run 'lecodes app sync')
7945
8392
  add takes a known id (camera, qr-scanner) or github:owner/repo[@tag]
7946
8393
  app open [ios] Open the native shell in its IDE (Xcode; Android Studio later)
@@ -7955,7 +8402,7 @@ ${c.bold("Commands:")}
7955
8402
  ASC API key; stored in ~/.lecodes/asc.json
7956
8403
 
7957
8404
  Config lives in ~/.lecodes/config.json (override with LECODES_API / LECODES_TOKEN).`;
7958
- var commands = { init: init2, login, create, clone, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, test, design, scene, dev, app };
8405
+ var commands = { init: init2, login, create, clone, link, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, test, types, design, scene, dev, app };
7959
8406
  if (STANDALONE)
7960
8407
  commands.install = install;
7961
8408
  var readVersion = () => package_default.version ?? "unknown";
@@ -7969,8 +8416,8 @@ var peerVersion = (name, probe) => {
7969
8416
  const require2 = createRequire4(import.meta.url);
7970
8417
  let dir = dirname16(require2.resolve(probe));
7971
8418
  for (let depth = 0;depth < 16; depth++) {
7972
- const manifest = join23(dir, "package.json");
7973
- if (existsSync22(manifest)) {
8419
+ const manifest = join24(dir, "package.json");
8420
+ if (existsSync23(manifest)) {
7974
8421
  const parsed = JSON.parse(readFileSync23(manifest, "utf8"));
7975
8422
  if (parsed.name === name)
7976
8423
  return parsed.version ?? "unknown";
@@ -7989,9 +8436,9 @@ var printVersion = () => {
7989
8436
  const version = peerVersion(name, probe);
7990
8437
  rows.push([name, version ?? c.dim("not installed")]);
7991
8438
  }
7992
- const width = Math.max(...rows.map(([label]) => label.length));
7993
- for (const [label, value] of rows)
7994
- log(`${label.padEnd(width)} ${value}`);
8439
+ const width = Math.max(...rows.map(([label2]) => label2.length));
8440
+ for (const [label2, value] of rows)
8441
+ log(`${label2.padEnd(width)} ${value}`);
7995
8442
  };
7996
8443
  var main = async () => {
7997
8444
  const argv = process.argv.slice(2);