lecodes-cli 0.9.2 → 0.10.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 (3) hide show
  1. package/README.md +4 -0
  2. package/dist/index.js +666 -227
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2088,7 +2088,7 @@ var require_main = __commonJS((exports, module) => {
2088
2088
  // package.json
2089
2089
  var package_default = {
2090
2090
  name: "lecodes-cli",
2091
- version: "0.9.2",
2091
+ version: "0.10.0",
2092
2092
  dependencies: {
2093
2093
  "@letary/chisel": "^0.5.1"
2094
2094
  },
@@ -3588,8 +3588,9 @@ var runtimePackageTemplate = (sdkVersion, variant, plugins) => {
3588
3588
  //
3589
3589
  // GENERATED by lecodes — regenerated by \`lecodes app sync\`; do not edit.
3590
3590
  // 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).
3591
+ // the compiled bundle uses, or forced via app.json ios.variant), the exact SDK release
3592
+ // (app.json ios.sdk — move it with \`lecodes app update\`) and one vendored target per
3593
+ // installed plugin (see plugins.lock.json).
3593
3594
 
3594
3595
  import PackageDescription
3595
3596
 
@@ -3600,7 +3601,7 @@ let package = Package(
3600
3601
  .library(name: "LeCodesRuntime", targets: ["LeCodesRuntime"]),
3601
3602
  ],
3602
3603
  dependencies: [
3603
- .package(url: "https://github.com/letary/lecodes-ios-sdk.git", from: "${sdkVersion}"),
3604
+ .package(url: "https://github.com/letary/lecodes-ios-sdk.git", exact: "${sdkVersion}"),
3604
3605
  ],
3605
3606
  targets: [
3606
3607
  .target(
@@ -3653,6 +3654,22 @@ lecodes app open # open App.xcodeproj in Xcode — build & run from there
3653
3654
  \`lecodes app doctor\` preflights builds; \`lecodes app build ios\` archives and exports an
3654
3655
  .ipa; \`lecodes app upload ios\` sends the archive to App Store Connect.
3655
3656
 
3657
+ ## SDK version
3658
+
3659
+ \`../app.json\` \`ios.sdk\` is the exact \`lecodes-ios-sdk\` release this shell links —
3660
+ \`lecodes app init\` pinned the newest one at the time, so builds stay reproducible.
3661
+
3662
+ \`\`\`
3663
+ lecodes app update # → newest SDK release + newest plugin tags, then sync
3664
+ lecodes app update --check # what's available, writes nothing
3665
+ lecodes app update --sdk 1.3.0
3666
+ \`\`\`
3667
+
3668
+ Set \`"sdk": "latest"\` instead of a version to re-resolve on every sync (needs network;
3669
+ offline it keeps whatever the shell already links). The pin is exact rather than a range
3670
+ because the CLI also downloads that release's filament resources bundle into
3671
+ \`App/Resources/\` — the two halves of one SDK must not drift apart.
3672
+
3656
3673
  ## Who owns what
3657
3674
 
3658
3675
  Yours — \`lecodes app sync\` never touches these:
@@ -3700,7 +3717,8 @@ buildNumber) — edit it there, then re-run \`lecodes app sync\`. Plugins are ma
3700
3717
  `;
3701
3718
 
3702
3719
  // src/commands/app.ts
3703
- var SDK_PACKAGE_VERSION = "1.2.0";
3720
+ var SDK_REPO = "letary/lecodes-ios-sdk";
3721
+ var SDK_MIN_VERSION = "1.2.0";
3704
3722
  var SDK_VARIANTS = ["core", "2d", "3d", "full"];
3705
3723
  var FIRST_PARTY_PLUGINS = {
3706
3724
  camera: "letary/lecodes-plugins",
@@ -3725,6 +3743,51 @@ var projectRoot = () => {
3725
3743
  }
3726
3744
  };
3727
3745
  var git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
3746
+ var VERSION_RE = /^\d+\.\d+\.\d+$/;
3747
+ var cmpVersion = (a, b) => {
3748
+ const pa = a.split(".").map(Number);
3749
+ const pb = b.split(".").map(Number);
3750
+ for (let i = 0;i < 3; i++) {
3751
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
3752
+ return (pa[i] ?? 0) - (pb[i] ?? 0);
3753
+ }
3754
+ return 0;
3755
+ };
3756
+ var fetchLatestSdkVersion = async () => {
3757
+ const url = `https://api.github.com/repos/${SDK_REPO}/releases/latest`;
3758
+ const res = await fetch(url, { headers: { accept: "application/vnd.github+json", "user-agent": "lecodes-cli" } });
3759
+ if (!res.ok)
3760
+ throw new CliError(`Could not read the latest ${SDK_REPO} release (HTTP ${res.status}) — ${url}`);
3761
+ const tag = (await res.json()).tag_name?.replace(/^v/, "") ?? "";
3762
+ if (!VERSION_RE.test(tag))
3763
+ throw new CliError(`${SDK_REPO} latest release has no x.y.z tag (got "${tag}")`);
3764
+ return tag;
3765
+ };
3766
+ var shellSdkVersion = (iosDir) => {
3767
+ const path = join11(iosDir, "LeCodesRuntime", "Package.swift");
3768
+ if (!existsSync8(path))
3769
+ return null;
3770
+ const m = /lecodes-ios-sdk\.git",\s*(?:exact|from):\s*"([^"]+)"/.exec(readFileSync10(path, "utf8"));
3771
+ return m && VERSION_RE.test(m[1]) ? m[1] : null;
3772
+ };
3773
+ var resolveSdkVersion = async (pin, fallback) => {
3774
+ if (pin !== "latest") {
3775
+ if (!VERSION_RE.test(pin)) {
3776
+ throw new CliError(`app.json ios.sdk must be "latest" or an exact x.y.z release (got "${pin}").`);
3777
+ }
3778
+ if (cmpVersion(pin, SDK_MIN_VERSION) < 0) {
3779
+ throw new CliError(`app.json ios.sdk ${pin} is older than the ${SDK_MIN_VERSION} these generated shells need.`);
3780
+ }
3781
+ return pin;
3782
+ }
3783
+ try {
3784
+ return await fetchLatestSdkVersion();
3785
+ } catch (e) {
3786
+ const kept = fallback ?? SDK_MIN_VERSION;
3787
+ warn(`Could not resolve the latest SDK (${e.message}) — staying on ${kept}.`);
3788
+ return kept;
3789
+ }
3790
+ };
3728
3791
  var ensureRepo = (repo, wantTag, lock) => {
3729
3792
  const override = process.env.LECODES_PLUGINS_DIR;
3730
3793
  if (override) {
@@ -3802,7 +3865,7 @@ var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+
3802
3865
  var appInit = async (args) => {
3803
3866
  const platform = args._[1];
3804
3867
  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]");
3868
+ 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
3869
  }
3807
3870
  const root = projectRoot();
3808
3871
  const iosDir = join11(root, "ios");
@@ -3814,6 +3877,9 @@ var appInit = async (args) => {
3814
3877
  if (variant !== "auto" && !SDK_VARIANTS.includes(variant)) {
3815
3878
  throw new CliError(`--variant must be auto, ${SDK_VARIANTS.join(", ")} (got "${variant}")`);
3816
3879
  }
3880
+ const sdkFlag = flagStr(args, "sdk") ?? existing.ios?.sdk;
3881
+ const sdkVersion = await resolveSdkVersion(process.env.LECODES_IOS_SDK ?? sdkFlag ?? "latest", null);
3882
+ const sdk = sdkFlag === "latest" ? "latest" : sdkVersion;
3817
3883
  const name = flagStr(args, "name") ?? existing.name ?? basename4(root);
3818
3884
  const config = {
3819
3885
  ...existing,
@@ -3824,6 +3890,7 @@ var appInit = async (args) => {
3824
3890
  bundleId: flagStr(args, "bundle-id") ?? existing.ios?.bundleId ?? `com.example.${slugify(name)}`,
3825
3891
  teamId: flagStr(args, "team") ?? existing.ios?.teamId ?? "",
3826
3892
  variant,
3893
+ sdk,
3827
3894
  buildNumber: existing.ios?.buildNumber ?? 1
3828
3895
  }
3829
3896
  };
@@ -3863,9 +3930,10 @@ ios/
3863
3930
  write("App/Assets.xcassets/Contents.json", ASSETS_ROOT_JSON);
3864
3931
  write("App/Assets.xcassets/AccentColor.colorset/Contents.json", ACCENT_COLOR_JSON);
3865
3932
  write("App/Assets.xcassets/AppIcon.appiconset/Contents.json", APP_ICON_JSON);
3866
- write("LeCodesRuntime/Package.swift", runtimePackageTemplate(SDK_PACKAGE_VERSION, variant === "auto" ? "core" : variant, []));
3933
+ write("LeCodesRuntime/Package.swift", runtimePackageTemplate(sdkVersion, variant === "auto" ? "core" : variant, []));
3867
3934
  write("LeCodesRuntime/Sources/LeCodesRuntime/LeCodesRuntime.swift", runtimeSwiftTemplate([]));
3868
3935
  log("");
3936
+ 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
3937
  success(`iOS shell for ${c.bold(name)} ready.`);
3870
3938
  log(` ${c.bold("lecodes app sync")} embed the compiled bundle (+ plugins) into the shell`);
3871
3939
  log(` ${c.bold("open ios/App.xcodeproj")} then run on your device from Xcode`);
@@ -3898,18 +3966,18 @@ var resolveVariant = (requested, bundleJs) => {
3898
3966
  }
3899
3967
  return { variant, detected };
3900
3968
  };
3901
- var ensureSdkResourcesBundle = async () => {
3969
+ var ensureSdkResourcesBundle = async (sdkVersion) => {
3902
3970
  const override = process.env.LECODES_SDK_RESOURCES;
3903
3971
  if (override) {
3904
3972
  note(`Using local resources bundle ${override} (LECODES_SDK_RESOURCES)`);
3905
3973
  return resolve2(override);
3906
3974
  }
3907
- const dir = join11(homedir2(), ".lecodes", "sdk-resources", SDK_PACKAGE_VERSION);
3975
+ const dir = join11(homedir2(), ".lecodes", "sdk-resources", sdkVersion);
3908
3976
  const bundle = join11(dir, "LeCodesSDKResources.bundle");
3909
3977
  if (existsSync8(bundle))
3910
3978
  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})…`);
3979
+ const url = `https://github.com/${SDK_REPO}/releases/download/${sdkVersion}/LeCodesSDKResources.bundle.zip`;
3980
+ note(`Downloading LeCodesSDKResources.bundle (SDK ${sdkVersion})…`);
3913
3981
  const res = await fetch(url);
3914
3982
  if (!res.ok)
3915
3983
  throw new CliError(`Resources bundle download failed (HTTP ${res.status}) — ${url}`);
@@ -3952,6 +4020,9 @@ var appSync = async (_args) => {
3952
4020
  if (requestedVariant !== "auto" && !SDK_VARIANTS.includes(requestedVariant)) {
3953
4021
  throw new CliError(`app.json ios.variant must be "auto" or one of ${SDK_VARIANTS.join(", ")} (got "${requestedVariant}").`);
3954
4022
  }
4023
+ const linkedSdk = shellSdkVersion(iosDir);
4024
+ const sdkPin = process.env.LECODES_IOS_SDK ?? config.ios.sdk ?? linkedSdk ?? "latest";
4025
+ const sdkVersion = await resolveSdkVersion(sdkPin, linkedSdk);
3955
4026
  const lockPath = join11(root, "plugins.lock.json");
3956
4027
  const oldLock = readJson(lockPath)?.plugins ?? {};
3957
4028
  const pluginSpecs = Object.entries(config.plugins ?? {});
@@ -3972,6 +4043,9 @@ var appSync = async (_args) => {
3972
4043
  const dest = join11(sourcesDir, module);
3973
4044
  rmSync(dest, { recursive: true, force: true });
3974
4045
  cpSync(src, dest, { recursive: true });
4046
+ if (ios.minSdk && VERSION_RE.test(ios.minSdk) && cmpVersion(sdkVersion, ios.minSdk) < 0) {
4047
+ warn(`Plugin "${id}" needs lecodes-ios-sdk ${ios.minSdk} but the shell links ${sdkVersion} — run \`lecodes app update\` (or set app.json ios.sdk).`);
4048
+ }
3975
4049
  for (const [key, value] of Object.entries(ios.infoPlist ?? {})) {
3976
4050
  (infoValues[key] ??= []).push(value);
3977
4051
  }
@@ -4003,9 +4077,11 @@ var appSync = async (_args) => {
4003
4077
  full: "uses the 3D + 2D engines"
4004
4078
  };
4005
4079
  log(`${c.green("variant")} ${variant}${requestedVariant === "auto" ? ` (auto: ${WHY[detected]})` : " (forced by ios.variant)"}`);
4080
+ const sdkWhy = sdkPin === "latest" ? "latest" : config.ios.sdk ? "pinned by ios.sdk" : "from the shell — set ios.sdk or run `lecodes app update`";
4081
+ log(`${c.green("sdk")} lecodes-ios-sdk ${sdkVersion}${c.dim(` (${sdkWhy})`)}`);
4006
4082
  const sdkProvided = new Set;
4007
4083
  if (variant === "3d" || variant === "full") {
4008
- const sdkResources = await ensureSdkResourcesBundle();
4084
+ const sdkResources = await ensureSdkResourcesBundle(sdkVersion);
4009
4085
  cpSync(sdkResources, join11(resourcesDir, "LeCodesSDKResources.bundle"), { recursive: true });
4010
4086
  for (const f2 of readdirSync4(sdkResources))
4011
4087
  if (f2 !== "Info.plist")
@@ -4043,7 +4119,7 @@ var appSync = async (_args) => {
4043
4119
  if (streamed.length > 0)
4044
4120
  log(`${c.green("remote")} ${streamed.length} resource(s) stay on the server (resources.remote): ${streamed.map((r) => r.path).join(", ")}`);
4045
4121
  writeFileSync7(join11(iosDir, "README.md"), IOS_README);
4046
- writeFileSync7(join11(iosDir, "LeCodesRuntime", "Package.swift"), runtimePackageTemplate(SDK_PACKAGE_VERSION, variant, runtimePlugins));
4122
+ writeFileSync7(join11(iosDir, "LeCodesRuntime", "Package.swift"), runtimePackageTemplate(sdkVersion, variant, runtimePlugins));
4047
4123
  writeFileSync7(join11(sourcesDir, "LeCodesRuntime", "LeCodesRuntime.swift"), runtimeSwiftTemplate(runtimePlugins));
4048
4124
  const mergedKeys = {};
4049
4125
  for (const [key, values] of Object.entries(infoValues))
@@ -4058,7 +4134,7 @@ var appSync = async (_args) => {
4058
4134
  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
4135
  writeFileSync7(pbxPath, pbx);
4060
4136
  log("");
4061
- success(`Shell synced: app.js + ${runtimePlugins.length} plugin(s), variant ${variant}, v${config.version ?? "1.0.0"} (${config.ios.buildNumber ?? 1}).`);
4137
+ success(`Shell synced: app.js + ${runtimePlugins.length} plugin(s), SDK ${sdkVersion}, variant ${variant}, v${config.version ?? "1.0.0"} (${config.ios.buildNumber ?? 1}).`);
4062
4138
  note("Build & run from Xcode (open ios/App.xcodeproj) — user files in ios/App/*.swift were not touched.");
4063
4139
  };
4064
4140
  var appPlugin = async (args) => {
@@ -4147,6 +4223,95 @@ var requireShell = () => {
4147
4223
  }
4148
4224
  return { root, config };
4149
4225
  };
4226
+ var latestPluginTag = (repo) => {
4227
+ try {
4228
+ return ensureRepo(repo, null, undefined).tag;
4229
+ } catch {
4230
+ return null;
4231
+ }
4232
+ };
4233
+ var appUpdate = async (args) => {
4234
+ const { root, config } = requireShell();
4235
+ const iosDir = join11(root, "ios");
4236
+ const checkOnly = flagBool(args, "check");
4237
+ const wanted = flagStr(args, "sdk");
4238
+ let changed = false;
4239
+ const pin = config.ios.sdk ?? shellSdkVersion(iosDir) ?? "latest";
4240
+ const current = pin === "latest" ? shellSdkVersion(iosDir) ?? SDK_MIN_VERSION : pin;
4241
+ let nextPin;
4242
+ if (wanted === "latest") {
4243
+ nextPin = "latest";
4244
+ } else if (wanted) {
4245
+ if (!VERSION_RE.test(wanted))
4246
+ throw new CliError(`--sdk must be "latest" or an exact x.y.z release (got "${wanted}").`);
4247
+ if (cmpVersion(wanted, SDK_MIN_VERSION) < 0)
4248
+ throw new CliError(`--sdk ${wanted} is older than the ${SDK_MIN_VERSION} these generated shells need.`);
4249
+ nextPin = wanted;
4250
+ } else {
4251
+ nextPin = await fetchLatestSdkVersion();
4252
+ }
4253
+ const nextVersion = nextPin === "latest" ? await fetchLatestSdkVersion() : nextPin;
4254
+ if (nextVersion === current && nextPin === pin) {
4255
+ log(`${c.green("sdk")} lecodes-ios-sdk ${current} ${c.dim("(already current)")}`);
4256
+ } else if (nextVersion === current) {
4257
+ log(`${c.green("sdk")} lecodes-ios-sdk ${current} → ios.sdk ${nextPin} ${c.dim("(same version, pin mode changed)")}`);
4258
+ changed = true;
4259
+ } else {
4260
+ const verb = cmpVersion(nextVersion, current) < 0 ? c.yellow("downgrade") : c.green("update");
4261
+ log(`${c.green("sdk")} lecodes-ios-sdk ${current} → ${c.bold(nextVersion)} ${c.dim(`(${verb})`)}`);
4262
+ changed = true;
4263
+ }
4264
+ if (!checkOnly && nextPin !== pin) {
4265
+ config.ios.sdk = nextPin;
4266
+ writeJson(join11(root, "app.json"), config);
4267
+ }
4268
+ const lockPath = join11(root, "plugins.lock.json");
4269
+ const lock = readJson(lockPath)?.plugins ?? {};
4270
+ const specs = Object.entries(config.plugins ?? {});
4271
+ if (specs.length > 0 && !flagBool(args, "no-plugins")) {
4272
+ const kept = {};
4273
+ for (const [id, spec] of specs) {
4274
+ const entry = lock[id];
4275
+ if (spec.startsWith("github:") && spec.includes("@")) {
4276
+ log(`${c.green("plugin")} ${id} ${c.dim(`pinned by app.json (${spec}) — not updated`)}`);
4277
+ if (entry)
4278
+ kept[id] = entry;
4279
+ continue;
4280
+ }
4281
+ const repo = spec.startsWith("github:") ? spec.slice("github:".length) : FIRST_PARTY_PLUGINS[id];
4282
+ const tag = repo ? latestPluginTag(repo) : null;
4283
+ if (!tag) {
4284
+ log(`${c.yellow("plugin")} ${id} ${c.dim("could not read its latest tag — left as is")}`);
4285
+ if (entry)
4286
+ kept[id] = entry;
4287
+ continue;
4288
+ }
4289
+ if (entry && entry.tag === tag) {
4290
+ log(`${c.green("plugin")} ${id} ${entry.tag} ${c.dim("(already current)")}`);
4291
+ kept[id] = entry;
4292
+ continue;
4293
+ }
4294
+ log(`${c.green("plugin")} ${id} ${entry?.tag ?? "—"} → ${c.bold(tag)}`);
4295
+ changed = true;
4296
+ }
4297
+ if (!checkOnly && existsSync8(lockPath))
4298
+ writeJson(lockPath, { plugins: kept });
4299
+ }
4300
+ log("");
4301
+ if (checkOnly) {
4302
+ note(changed ? "Run `lecodes app update` to apply." : "Everything is up to date.");
4303
+ return;
4304
+ }
4305
+ if (!changed) {
4306
+ success("Already up to date.");
4307
+ return;
4308
+ }
4309
+ if (flagBool(args, "no-sync")) {
4310
+ success("Pins updated — run `lecodes app sync` to apply them to the shell.");
4311
+ return;
4312
+ }
4313
+ await appSync({ _: [], flags: {} });
4314
+ };
4150
4315
  var appDoctor = async (_args) => {
4151
4316
  const { root, config } = requireShell();
4152
4317
  let failed = 0;
@@ -4177,6 +4342,18 @@ var appDoctor = async (_args) => {
4177
4342
  const hasResources = existsSync8(join11(root, "ios", "App", "Resources", "LeCodesSDKResources.bundle"));
4178
4343
  check(pkg.includes(`"LeCodesSDK-${variant}"`) && (!needsResources || hasResources), `SDK variant ${variant}${needsResources ? " (+ filament resources)" : ""}`, "run `lecodes app sync`");
4179
4344
  }
4345
+ const linked = shellSdkVersion(join11(root, "ios"));
4346
+ const pin = config.ios.sdk ?? "latest";
4347
+ check(!!linked, `SDK ${linked ?? "version unreadable"}`, "run `lecodes app sync`");
4348
+ if (linked && pin !== "latest") {
4349
+ check(linked === pin, `SDK matches app.json ios.sdk (${pin})`, "run `lecodes app sync`");
4350
+ }
4351
+ if (linked) {
4352
+ try {
4353
+ const latest = await fetchLatestSdkVersion();
4354
+ check(cmpVersion(linked, latest) >= 0, `SDK is the latest release (${latest})`, "run `lecodes app update`", false);
4355
+ } catch {}
4356
+ }
4180
4357
  const lock = readJson(join11(root, "plugins.lock.json"))?.plugins ?? {};
4181
4358
  const wanted = Object.keys(config.plugins ?? {});
4182
4359
  const inSync = wanted.every((id) => lock[id]) && Object.keys(lock).every((id) => wanted.includes(id));
@@ -4303,6 +4480,8 @@ var app = async (args) => {
4303
4480
  return appInit(args);
4304
4481
  if (sub === "sync")
4305
4482
  return appSync(args);
4483
+ if (sub === "update")
4484
+ return appUpdate(args);
4306
4485
  if (sub === "plugin")
4307
4486
  return appPlugin(args);
4308
4487
  if (sub === "doctor")
@@ -4313,16 +4492,16 @@ var app = async (args) => {
4313
4492
  return appUpload(args);
4314
4493
  if (sub === "open")
4315
4494
  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`.");
4495
+ throw new CliError("Usage: lecodes app <init ios | sync | update | open | plugin list|add|remove | doctor | build ios | upload ios> — see `lecodes help`.");
4317
4496
  };
4318
4497
 
4319
4498
  // src/commands/clone.ts
4320
- import { existsSync as existsSync10, mkdirSync as mkdirSync7, readdirSync as readdirSync5 } from "node:fs";
4499
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, readdirSync as readdirSync6 } from "node:fs";
4321
4500
  import { resolve as resolve3 } from "node:path";
4322
4501
 
4323
4502
  // 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";
4503
+ import { dirname as dirname8, join as join12, relative as relative3, sep } from "node:path";
4504
+ 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
4505
  var TSCONFIG = {
4327
4506
  compilerOptions: {
4328
4507
  target: "ES2020",
@@ -4339,17 +4518,60 @@ var TSCONFIG = {
4339
4518
  include: [`${LECODES_DIR}/types/**/*.d.ts`, "**/*.ts", "**/*.js"],
4340
4519
  exclude: ["node_modules"]
4341
4520
  };
4521
+ var listBundleFiles = (dir, base = dir) => {
4522
+ if (!existsSync9(dir))
4523
+ return [];
4524
+ const out = [];
4525
+ for (const name of readdirSync5(dir)) {
4526
+ const path = join12(dir, name);
4527
+ if (statSync3(path).isDirectory())
4528
+ out.push(...listBundleFiles(path, base));
4529
+ else
4530
+ out.push(relative3(base, path).split(sep).join("/"));
4531
+ }
4532
+ return out;
4533
+ };
4534
+ var pruneEmptyDirs = (dir, root) => {
4535
+ if (!existsSync9(dir) || !statSync3(dir).isDirectory())
4536
+ return;
4537
+ for (const name of readdirSync5(dir))
4538
+ pruneEmptyDirs(join12(dir, name), root);
4539
+ if (dir !== root && readdirSync5(dir).length === 0)
4540
+ rmSync2(dir, { recursive: true });
4541
+ };
4342
4542
  var writeTypeBundle = (root, bundle) => {
4343
4543
  const typesDir = join12(root, LECODES_DIR, "types");
4544
+ const before = new Set(listBundleFiles(typesDir));
4545
+ const stats = { added: 0, updated: 0, removed: 0, total: Object.keys(bundle).length };
4344
4546
  for (const [path, text] of Object.entries(bundle)) {
4345
4547
  const dest = join12(typesDir, path);
4548
+ if (!before.has(path))
4549
+ stats.added++;
4550
+ else if (readFileSync11(dest, "utf8") !== text)
4551
+ stats.updated++;
4552
+ else
4553
+ continue;
4346
4554
  mkdirSync6(dirname8(dest), { recursive: true });
4347
4555
  writeFileSync8(dest, text);
4348
4556
  }
4557
+ for (const path of before) {
4558
+ if (path in bundle)
4559
+ continue;
4560
+ rmSync2(join12(typesDir, path));
4561
+ stats.removed++;
4562
+ }
4563
+ pruneEmptyDirs(typesDir, typesDir);
4564
+ return stats;
4349
4565
  };
4350
- var materializeTypes = async (root, apiUrl) => {
4351
- writeTypeBundle(root, await getTypes(apiUrl));
4566
+ var bundleHash = (bundle) => sha256(Object.keys(bundle).sort().map((k) => `${k}\x00${bundle[k]}`).join("\x00"));
4567
+ var readProjectTypeBundle = (root) => {
4568
+ const typesDir = join12(root, LECODES_DIR, "types");
4569
+ const bundle = {};
4570
+ for (const path of listBundleFiles(typesDir))
4571
+ bundle[path] = readFileSync11(join12(typesDir, path), "utf8");
4572
+ return bundle;
4352
4573
  };
4574
+ var materializeTypes = async (root, apiUrl) => writeTypeBundle(root, await getTypes(apiUrl));
4353
4575
  var readLocalTypeBundle = () => {
4354
4576
  const candidates = [
4355
4577
  join12(distRoot, "runtime", "sdk-types.json"),
@@ -4394,7 +4616,7 @@ var clone = async (args) => {
4394
4616
  const project = await getProject(apiUrl, token, uuid);
4395
4617
  const dir = args._[1] ?? slug(project.name);
4396
4618
  const root = resolve3(dir);
4397
- if (existsSync10(root) && readdirSync5(root).length > 0) {
4619
+ if (existsSync10(root) && readdirSync6(root).length > 0) {
4398
4620
  throw new CliError(`Directory "${dir}" already exists and is not empty.`);
4399
4621
  }
4400
4622
  mkdirSync7(root, { recursive: true });
@@ -4414,7 +4636,7 @@ var clone = async (args) => {
4414
4636
  };
4415
4637
 
4416
4638
  // src/commands/create.ts
4417
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync9 } from "node:fs";
4639
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readdirSync as readdirSync7, writeFileSync as writeFileSync9 } from "node:fs";
4418
4640
  import { join as join13, resolve as resolve4 } from "node:path";
4419
4641
 
4420
4642
  // src/commands/projectTemplates.ts
@@ -4501,7 +4723,7 @@ var create = async (args) => {
4501
4723
  throw new CliError("Usage: lecodes create <name> [directory]");
4502
4724
  const dir = args._[1] ?? slug2(name);
4503
4725
  const root = resolve4(dir);
4504
- if (existsSync11(root) && readdirSync6(root).length > 0) {
4726
+ if (existsSync11(root) && readdirSync7(root).length > 0) {
4505
4727
  throw new CliError(`Directory "${dir}" already exists and is not empty.`);
4506
4728
  }
4507
4729
  mkdirSync8(root, { recursive: true });
@@ -4520,7 +4742,7 @@ var create = async (args) => {
4520
4742
  };
4521
4743
 
4522
4744
  // 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";
4745
+ import { existsSync as existsSync13, mkdirSync as mkdirSync10, readFileSync as readFileSync14, readdirSync as readdirSync8, writeFileSync as writeFileSync11, appendFileSync } from "node:fs";
4524
4746
  import { basename as basename6, join as join15 } from "node:path";
4525
4747
 
4526
4748
  // src/compile/designCompile.ts
@@ -5507,7 +5729,7 @@ var listScreens = (designDir) => {
5507
5729
  const dir = join15(designDir, "screens");
5508
5730
  if (!existsSync13(dir))
5509
5731
  return [];
5510
- return readdirSync7(dir).filter((f2) => f2.endsWith(".ts") && !f2.startsWith(".")).map((f2) => f2.slice(0, -3)).sort();
5732
+ return readdirSync8(dir).filter((f2) => f2.endsWith(".ts") && !f2.startsWith(".")).map((f2) => f2.slice(0, -3)).sort();
5511
5733
  };
5512
5734
  var readMetaSizes = (designDir) => {
5513
5735
  try {
@@ -5876,7 +6098,7 @@ import { basename as basename7, join as join17 } from "node:path";
5876
6098
 
5877
6099
  // src/dev/devServer.ts
5878
6100
  import { createServer as createServer2 } from "node:http";
5879
- import { createReadStream, existsSync as existsSync14, statSync as statSync3 } from "node:fs";
6101
+ import { createReadStream, existsSync as existsSync14, statSync as statSync4 } from "node:fs";
5880
6102
  import { join as join16 } from "node:path";
5881
6103
 
5882
6104
  // src/dev/clientTemplates.ts
@@ -6247,7 +6469,7 @@ var startDevServer = async (options) => {
6247
6469
  return sendText(res, 400, "text/plain", "Bad path");
6248
6470
  }
6249
6471
  const abs = join16(root, key.slice(1));
6250
- if (!existsSync14(abs) || !statSync3(abs).isFile())
6472
+ if (!existsSync14(abs) || !statSync4(abs).isFile())
6251
6473
  return sendText(res, 404, "text/plain", "Not found");
6252
6474
  res.writeHead(200, { "content-type": contentType(key), "cache-control": "no-store" });
6253
6475
  createReadStream(abs).pipe(res);
@@ -6706,6 +6928,19 @@ lecodes test # run tests/*.flow.json — the starter ships w
6706
6928
  Scenario format + conventions: see [CLAUDE.md](./CLAUDE.md) (written for AI assistants, useful
6707
6929
  for humans too).
6708
6930
 
6931
+ ## Put it on the platform
6932
+
6933
+ This project is local-only. To give it a home on le.codes (web editor, sharing, OTA updates):
6934
+
6935
+ \`\`\`
6936
+ lecodes login # once per machine
6937
+ lecodes link # create a project from this folder and connect it
6938
+ lecodes push -m "initial" # upload it as the first checkpoint (--publish to go live)
6939
+ \`\`\`
6940
+
6941
+ Already made the project in the web editor? \`lecodes link --project <uuid>\` adopts it — it
6942
+ shows what your next push would change there before connecting anything.
6943
+
6709
6944
  ## Go native
6710
6945
 
6711
6946
  \`\`\`
@@ -6722,7 +6957,8 @@ lecodes app open # open it in Xcode, build & run
6722
6957
  \`tests/.artifacts/\`
6723
6958
  - \`CLAUDE.md\` — guidance for AI assistants (the headless feedback loop + testability conventions)
6724
6959
  - \`assets/\` — images/fonts the app references (created on demand)
6725
- - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit)
6960
+ - \`tsconfig.json\` + \`.lecodes/types/\` — generated IDE types (rewritten by the CLI; don't edit).
6961
+ They come from the SDK bundled in the CLI; \`lecodes types\` refreshes them from the platform.
6726
6962
  - \`.lecodesignore\` — files a future \`lecodes push\` would skip
6727
6963
 
6728
6964
  Full command list: \`lecodes --help\`.
@@ -6763,6 +6999,231 @@ var init2 = async (args) => {
6763
6999
  log(` ${c.bold(cd + "lecodes compile -o app.js")} bundle it for LeCodes Desktop`);
6764
7000
  };
6765
7001
 
7002
+ // src/commands/link.ts
7003
+ import { existsSync as existsSync17 } from "node:fs";
7004
+ import { basename as basename11, join as join19, resolve as resolve6 } from "node:path";
7005
+
7006
+ // src/commands/push.ts
7007
+ import { basename as basename10 } from "node:path";
7008
+ import { readFileSync as readFileSync17 } from "node:fs";
7009
+ var push = async (args) => {
7010
+ const root = findProjectRoot(process.cwd());
7011
+ const manifest = readManifest(root);
7012
+ const config = loadConfig();
7013
+ const apiUrl = requireApiUrl(config);
7014
+ const token = requireToken(config);
7015
+ const publish = flagBool(args, "publish");
7016
+ const scan = scanProject(root, manifest);
7017
+ const diff2 = diffLocal(manifest, scan);
7018
+ if (!hasChanges(diff2)) {
7019
+ if (publish) {
7020
+ await publishProject(apiUrl, token, manifest.uuid);
7021
+ success("No file changes — published the current project state.");
7022
+ } else {
7023
+ info(c.dim("Working tree clean — nothing to push."));
7024
+ }
7025
+ return;
7026
+ }
7027
+ const force = flagBool(args, "force");
7028
+ if (!force && manifest.baseHeadId !== null) {
7029
+ const commits = await getCommits(apiUrl, token, manifest.uuid);
7030
+ if (commits.headId !== manifest.baseHeadId) {
7031
+ 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.");
7032
+ }
7033
+ if (commits.dirty) {
7034
+ const drift = serverPathDrift(await getProject(apiUrl, token, manifest.uuid), manifest);
7035
+ if (drift) {
7036
+ 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.`);
7037
+ }
7038
+ }
7039
+ }
7040
+ if (force) {
7041
+ const project = await getProject(apiUrl, token, manifest.uuid);
7042
+ const serverDiff = computeServerDiff(project, scan);
7043
+ if (serverDiff.length) {
7044
+ info(c.yellow(`--force will overwrite the server. ${serverDiff.length} file${serverDiff.length === 1 ? "" : "s"} change:`));
7045
+ const label = { added: c.green(" create "), removed: c.red(" delete "), modified: c.yellow(" update ") };
7046
+ for (const e of serverDiff)
7047
+ log(label[e.status] + e.path + (e.binary ? c.dim(" (binary)") : ""));
7048
+ if (process.stdin.isTTY && !flagBool(args, "y", "yes")) {
7049
+ if (!await confirm("Overwrite the server with your local copy?", false)) {
7050
+ info(c.dim("Aborted."));
7051
+ return;
7052
+ }
7053
+ }
7054
+ }
7055
+ }
7056
+ const message = (flagStr(args, "m", "message") ?? await prompt("Checkpoint message")) || "Update from lecodes CLI";
7057
+ const sourceFor = (path) => manifest.files[path] ?? (diff2.moves.has(path) ? manifest.files[diff2.moves.get(path)] : undefined);
7058
+ const files = [];
7059
+ let uploaded = 0;
7060
+ for (const file of scan.files) {
7061
+ const source = sourceFor(file.path);
7062
+ const kind = source?.type ?? classifyKind(basename10(file.path));
7063
+ const entry = { path: file.path, type: kind };
7064
+ if (source)
7065
+ entry.assetId = source.id;
7066
+ if (isTextKind(kind)) {
7067
+ entry.text = readFileSync17(file.absPath, "utf8");
7068
+ } else if (!source || source.sha !== file.sha) {
7069
+ const blob = await uploadBlob(apiUrl, token, manifest.uuid, basename10(file.path), readFileSync17(file.absPath));
7070
+ entry.blobFileId = blob.id;
7071
+ uploaded++;
7072
+ }
7073
+ files.push(entry);
7074
+ }
7075
+ const folders = scan.dirs.map((path) => ({ path }));
7076
+ if (uploaded > 0)
7077
+ info(c.dim(`Uploaded ${uploaded} binary file${uploaded === 1 ? "" : "s"}.`));
7078
+ const res = await sync(apiUrl, token, manifest.uuid, { message, files, folders, publish });
7079
+ if (!res.created) {
7080
+ info(c.dim("Server reported no changes."));
7081
+ if (publish)
7082
+ reportPublish(res);
7083
+ return;
7084
+ }
7085
+ const shaByPath = new Map(scan.files.map((f2) => [f2.path, f2.sha]));
7086
+ const newFiles = {};
7087
+ for (const node of res.nodes) {
7088
+ newFiles[node.path] = { id: node.id, type: node.type, sha: shaByPath.get(node.path) ?? "" };
7089
+ }
7090
+ writeManifest(root, { ...manifest, baseHeadId: res.headId, files: newFiles });
7091
+ const counts = [
7092
+ diff2.added.length - diff2.moves.size && `${diff2.added.length - diff2.moves.size} added`,
7093
+ diff2.modified.length && `${diff2.modified.length} modified`,
7094
+ diff2.moves.size && `${diff2.moves.size} moved`,
7095
+ diff2.deleted.length - diff2.moves.size && `${diff2.deleted.length - diff2.moves.size} deleted`
7096
+ ].filter(Boolean).join(", ");
7097
+ success(`Pushed checkpoint${res.seq ? ` #${res.seq}` : ""}${counts ? ` (${counts})` : ""}: ${message}`);
7098
+ if (publish)
7099
+ reportPublish(res);
7100
+ };
7101
+ var serverPathDrift = (project, manifest) => {
7102
+ const pathById = buildPaths(project.assets);
7103
+ const serverPaths = new Set(project.assets.filter((a) => a.type !== "folder").map((a) => pathById.get(a.id)));
7104
+ const manifestPaths = new Set(Object.keys(manifest.files));
7105
+ const added = [...serverPaths].filter((p) => !manifestPaths.has(p));
7106
+ const removed = [...manifestPaths].filter((p) => !serverPaths.has(p));
7107
+ const describe = (label, paths) => `${paths.length} ${label} on the server: ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", …" : ""}`;
7108
+ const parts = [
7109
+ added.length ? describe("added", added) : "",
7110
+ removed.length ? describe("removed", removed) : ""
7111
+ ].filter(Boolean);
7112
+ return parts.length ? parts.join("; ") : null;
7113
+ };
7114
+ var reportPublish = (res) => {
7115
+ if (res.published)
7116
+ success("Published — the project is now live.");
7117
+ else
7118
+ warn(`Push landed, but publish failed: ${res.publishError ?? "unknown error"}`);
7119
+ };
7120
+
7121
+ // src/commands/link.ts
7122
+ var kindOf2 = (type) => type === "file" ? "resource" : type;
7123
+ var manifestFromServer = (project) => {
7124
+ const pathById = buildPaths(project.assets);
7125
+ const files = {};
7126
+ for (const asset of project.assets) {
7127
+ if (asset.type === "folder")
7128
+ continue;
7129
+ files[pathById.get(asset.id)] = {
7130
+ id: asset.id,
7131
+ type: kindOf2(asset.type),
7132
+ sha: asset.type === "file" ? "" : sha256(asset.text ?? "")
7133
+ };
7134
+ }
7135
+ return files;
7136
+ };
7137
+ var confirmAdoption = async (root, project, files, args) => {
7138
+ const count = Object.keys(files).length;
7139
+ if (count === 0) {
7140
+ info(c.dim(`Project ${c.bold(project.name)} is empty on the server — nothing to overwrite.`));
7141
+ return true;
7142
+ }
7143
+ const scan = scanProject(root, { files });
7144
+ const diff2 = computeServerDiff(project, scan);
7145
+ if (diff2.length === 0) {
7146
+ info(c.dim(`Project ${c.bold(project.name)} (${count} file(s)) already matches this folder.`));
7147
+ return true;
7148
+ }
7149
+ warn(`${c.bold(project.name)} already has ${count} file(s). Your next push would change the server:`);
7150
+ const label = { added: c.green(" create "), removed: c.red(" delete "), modified: c.yellow(" update ") };
7151
+ for (const e of diff2)
7152
+ log(label[e.status] + e.path + (e.binary ? c.dim(" (binary)") : ""));
7153
+ log("");
7154
+ note("`lecodes pull` after linking takes the server's version instead.");
7155
+ if (flagBool(args, "y", "yes") || flagBool(args, "force") || !process.stdin.isTTY)
7156
+ return true;
7157
+ return confirm("Link this folder to that project?", false);
7158
+ };
7159
+ var link = async (args) => {
7160
+ const config = loadConfig();
7161
+ const apiUrl = requireApiUrl(config);
7162
+ const token = requireToken(config);
7163
+ const dirArg = args._[0];
7164
+ const root = dirArg ? resolve6(dirArg) : process.cwd();
7165
+ if (!existsSync17(root))
7166
+ throw new CliError(`No such directory: ${root}`);
7167
+ if (existsSync17(join19(root, LECODES_DIR, "manifest.json")) && !flagBool(args, "force")) {
7168
+ 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.");
7169
+ }
7170
+ let enclosing = null;
7171
+ try {
7172
+ enclosing = findProjectRoot(root);
7173
+ } catch {}
7174
+ if (enclosing && enclosing !== root) {
7175
+ throw new CliError(`This folder is inside the linked project at ${enclosing} — push from there instead.`);
7176
+ }
7177
+ const projectRef = flagStr(args, "project");
7178
+ let uuid;
7179
+ let name;
7180
+ let files;
7181
+ if (projectRef) {
7182
+ uuid = parseUuid(projectRef);
7183
+ const project = await getProject(apiUrl, token, uuid);
7184
+ if (project.role === "viewer") {
7185
+ throw new CliError(`You have view-only access to ${project.name} — linking would give you a checkout you can't push.`);
7186
+ }
7187
+ files = manifestFromServer(project);
7188
+ if (!await confirmAdoption(root, project, files, args)) {
7189
+ info(c.dim("Aborted — nothing was linked."));
7190
+ return;
7191
+ }
7192
+ name = project.name;
7193
+ } else {
7194
+ name = flagStr(args, "name") ?? basename11(root);
7195
+ info(c.dim(`Creating project ${c.bold(name)}…`));
7196
+ uuid = (await createProject(apiUrl, token, name, flagStr(args, "team"))).uuid;
7197
+ files = {};
7198
+ }
7199
+ const commits = await getCommits(apiUrl, token, uuid);
7200
+ writeManifest(root, { uuid, apiUrl, name, baseHeadId: commits.headId, files });
7201
+ log(`${c.green("linked")} ${c.bold(name)} ${c.dim(uuid)}`);
7202
+ if (!flagBool(args, "no-types")) {
7203
+ await materializeTypes(root, apiUrl);
7204
+ writeTsconfig(root);
7205
+ log(`${c.green("written")} ${LECODES_DIR}/types/ + tsconfig.json ${c.dim("(from the server)")}`);
7206
+ }
7207
+ writeDefaultIgnore(root);
7208
+ if (flagBool(args, "push")) {
7209
+ log("");
7210
+ if (root !== process.cwd())
7211
+ process.chdir(root);
7212
+ await push(args);
7213
+ return;
7214
+ }
7215
+ const pending = scanProject(root, { files }).files.filter((f2) => {
7216
+ const known = files[f2.path];
7217
+ return !known || known.sha !== f2.sha;
7218
+ }).length;
7219
+ log("");
7220
+ success(`Connected to ${c.bold(name)}.`);
7221
+ const cd = dirArg ? `cd ${dirArg} && ` : "";
7222
+ log(` ${c.bold(cd + "lecodes status")} ${pending} file(s) would go up`);
7223
+ log(` ${c.bold(cd + 'lecodes push -m "initial"')} push them as the first checkpoint`);
7224
+ log(` ${c.dim("(add --publish to deploy it live in the same step)")}`);
7225
+ };
7226
+
6766
7227
  // src/commands/login.ts
6767
7228
  var login = async (args) => {
6768
7229
  const config = loadConfig();
@@ -6775,8 +7236,8 @@ var login = async (args) => {
6775
7236
  };
6776
7237
 
6777
7238
  // 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";
7239
+ import { mkdirSync as mkdirSync13, readFileSync as readFileSync18, writeFileSync as writeFileSync14 } from "node:fs";
7240
+ import { dirname as dirname11, resolve as resolve7 } from "node:path";
6780
7241
 
6781
7242
  // src/compile/headlessBundle.ts
6782
7243
  var LOCAL_ASSET_SCHEME2 = "lecodesfile://";
@@ -6877,7 +7338,7 @@ var requireRenderer = async () => {
6877
7338
  };
6878
7339
  var fileScenarioIO = (dir) => ({
6879
7340
  write: (relPath, data) => {
6880
- const abs = resolve6(dir, relPath);
7341
+ const abs = resolve7(dir, relPath);
6881
7342
  mkdirSync13(dirname11(abs), { recursive: true });
6882
7343
  writeFileSync14(abs, data);
6883
7344
  }
@@ -6887,9 +7348,9 @@ var resolveFixtures = (value, baseDir) => {
6887
7348
  return;
6888
7349
  let map = value;
6889
7350
  if (typeof value === "string") {
6890
- const abs = resolve6(baseDir, value);
7351
+ const abs = resolve7(baseDir, value);
6891
7352
  try {
6892
- map = JSON.parse(readFileSync17(abs, "utf8"));
7353
+ map = JSON.parse(readFileSync18(abs, "utf8"));
6893
7354
  } catch (e) {
6894
7355
  throw new CliError(`Can't read fixtures "${value}": ${e instanceof Error ? e.message : String(e)}`);
6895
7356
  }
@@ -6902,7 +7363,7 @@ var resolveFixtures = (value, baseDir) => {
6902
7363
  var readScenarioFile = (path) => {
6903
7364
  let raw;
6904
7365
  try {
6905
- raw = JSON.parse(readFileSync17(resolve6(path), "utf8"));
7366
+ raw = JSON.parse(readFileSync18(resolve7(path), "utf8"));
6906
7367
  } catch (e) {
6907
7368
  throw new CliError(`Can't read scenario "${path}": ${e instanceof Error ? e.message : String(e)}`);
6908
7369
  }
@@ -6932,7 +7393,7 @@ var render = async (args) => {
6932
7393
  const settleStr = flagStr(args, "settle") ?? (scenario?.settle !== undefined ? String(scenario.settle) : undefined);
6933
7394
  const timeStr = flagStr(args, "time");
6934
7395
  const onConsole = flagBool(args, "logs") ? (level, text2) => logErr(`${c.dim(`[${level}]`)} ${text2}`) : undefined;
6935
- const envelopeFixtures = resolveFixtures(scenario?.fixtures, scriptFlag !== undefined ? dirname11(resolve6(scriptFlag)) : ".");
7396
+ const envelopeFixtures = resolveFixtures(scenario?.fixtures, scriptFlag !== undefined ? dirname11(resolve7(scriptFlag)) : ".");
6936
7397
  const flagFixtures = resolveFixtures(flagStr(args, "fixtures"), ".");
6937
7398
  const fixtures = envelopeFixtures || flagFixtures ? { ...envelopeFixtures, ...flagFixtures } : undefined;
6938
7399
  if (fixtures) {
@@ -6971,7 +7432,7 @@ var render = async (args) => {
6971
7432
  ${c.red(st.error ?? "failed")}`);
6972
7433
  }
6973
7434
  if (!result2.ok && result2.failureArtifacts.length > 0) {
6974
- logErr(c.dim(` failing frame: ${result2.failureArtifacts.map((p) => resolve6(outDir, p)).join(", ")}`));
7435
+ logErr(c.dim(` failing frame: ${result2.failureArtifacts.map((p) => resolve7(outDir, p)).join(", ")}`));
6975
7436
  }
6976
7437
  logErr(result2.ok ? c.green(`✓ ${result2.steps.length}/${steps.length} steps passed`) : c.red(`✗ failed at step ${result2.steps.length} of ${steps.length}`));
6977
7438
  return done((cb) => cb(), result2.ok ? 0 : 1);
@@ -6993,7 +7454,7 @@ var render = async (args) => {
6993
7454
  if (isNullSink(pngFlag)) {
6994
7455
  note(`Discarded PNG (${pngFlag})`);
6995
7456
  } else {
6996
- writeFileSync14(resolve6(pngFlag), png);
7457
+ writeFileSync14(resolve7(pngFlag), png);
6997
7458
  note(`Wrote ${pngFlag} (${png.length} bytes, ${width}x${height})`);
6998
7459
  }
6999
7460
  return done((cb) => cb());
@@ -7008,7 +7469,7 @@ var render = async (args) => {
7008
7469
  return done((cb) => cb());
7009
7470
  }
7010
7471
  if (out) {
7011
- writeFileSync14(resolve6(out), result.text);
7472
+ writeFileSync14(resolve7(out), result.text);
7012
7473
  note(`Wrote ${out} (${result.text.length} bytes)`);
7013
7474
  return done((cb) => cb());
7014
7475
  }
@@ -7019,8 +7480,8 @@ var render = async (args) => {
7019
7480
  };
7020
7481
 
7021
7482
  // 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";
7483
+ import { existsSync as existsSync18, mkdirSync as mkdirSync14, rmSync as rmSync3, writeFileSync as writeFileSync15 } from "node:fs";
7484
+ import { dirname as dirname12, join as join20 } from "node:path";
7024
7485
  var pull = async (args) => {
7025
7486
  const root = findProjectRoot(process.cwd());
7026
7487
  const manifest = readManifest(root);
@@ -7040,9 +7501,9 @@ var pull = async (args) => {
7040
7501
  const remotePaths = new Set([...buildPaths(project.assets).entries()].filter(([id]) => project.assets.find((a) => a.id === id)?.type !== "folder").map(([, path]) => path));
7041
7502
  for (const path of Object.keys(manifest.files)) {
7042
7503
  if (!remotePaths.has(path)) {
7043
- const abs = join19(root, ...path.replace(/^\//, "").split("/"));
7044
- if (existsSync17(abs))
7045
- rmSync2(abs);
7504
+ const abs = join20(root, ...path.replace(/^\//, "").split("/"));
7505
+ if (existsSync18(abs))
7506
+ rmSync3(abs);
7046
7507
  }
7047
7508
  }
7048
7509
  const files = await materializeAssets(root, project, apiUrl, token, progress);
@@ -7055,7 +7516,7 @@ var pull = async (args) => {
7055
7516
  writeManifest(root, { ...manifest, name: project.name, baseHeadId: commits.headId, files });
7056
7517
  success(`Pulled latest into ${c.bold(manifest.name)}.`);
7057
7518
  };
7058
- var kindOf2 = (type) => type === "file" ? "resource" : type;
7519
+ var kindOf3 = (type) => type === "file" ? "resource" : type;
7059
7520
  var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
7060
7521
  const pathById = buildPaths(project.assets);
7061
7522
  const byPath = new Map;
@@ -7079,7 +7540,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
7079
7540
  const files = { ...manifest.files };
7080
7541
  for (const asset of targets) {
7081
7542
  const key = pathById.get(asset.id);
7082
- const abs = join19(root, ...key.replace(/^\//, "").split("/"));
7543
+ const abs = join20(root, ...key.replace(/^\//, "").split("/"));
7083
7544
  mkdirSync14(dirname12(abs), { recursive: true });
7084
7545
  let sha;
7085
7546
  if (asset.type === "file") {
@@ -7093,7 +7554,7 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
7093
7554
  writeFileSync15(abs, text);
7094
7555
  sha = sha256(text);
7095
7556
  }
7096
- files[key] = { id: asset.id, type: kindOf2(asset.type), sha };
7557
+ files[key] = { id: asset.id, type: kindOf3(asset.type), sha };
7097
7558
  info(c.green(` pulled ${key}`));
7098
7559
  }
7099
7560
  writeManifest(root, { ...manifest, files });
@@ -7101,137 +7562,22 @@ var pullPaths = async (root, manifest, project, paths, apiUrl, token) => {
7101
7562
  info(c.dim("Local edits to other files are untouched. Run `lecodes diff` to review, then `lecodes push --force`."));
7102
7563
  };
7103
7564
 
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
7565
  // 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";
7566
+ import { existsSync as existsSync20, readFileSync as readFileSync20, readdirSync as readdirSync9, statSync as statSync5, writeFileSync as writeFileSync16 } from "node:fs";
7567
+ import { basename as basename12, dirname as dirname13, join as join22, resolve as resolve8 } from "node:path";
7222
7568
 
7223
7569
  // src/compile/sceneCompile.ts
7224
- import { existsSync as existsSync18, readFileSync as readFileSync19 } from "node:fs";
7225
- import { join as join20 } from "node:path";
7570
+ import { existsSync as existsSync19, readFileSync as readFileSync19 } from "node:fs";
7571
+ import { join as join21 } from "node:path";
7226
7572
  var TEXT_EXT = /\.(ts|js|json|svg|txt|md)$/i;
7227
7573
  var rel = (key) => key.replace(/^\//, "");
7228
7574
  var resolveHarness = (loadSceneHarness2) => {
7229
- const inRepo = join20(distRoot, "..", "projects", "src", "scene-editor");
7575
+ const inRepo = join21(distRoot, "..", "projects", "src", "scene-editor");
7230
7576
  try {
7231
- return loadSceneHarness2(existsSync18(inRepo) ? inRepo : undefined);
7577
+ return loadSceneHarness2(existsSync19(inRepo) ? inRepo : undefined);
7232
7578
  } catch {
7233
- const vendored = join20(distRoot, "runtime", "scene-harness.json");
7234
- if (existsSync18(vendored))
7579
+ const vendored = join21(distRoot, "runtime", "scene-harness.json");
7580
+ if (existsSync19(vendored))
7235
7581
  return JSON.parse(readFileSync19(vendored, "utf8"));
7236
7582
  throw new CliError("scene-editor harness sources not found — reinstall the CLI or set LECODES_SCENE_HARNESS_DIR.");
7237
7583
  }
@@ -7284,22 +7630,22 @@ var getContext2 = (args) => {
7284
7630
  manifest = null;
7285
7631
  const pathArg = args._[0];
7286
7632
  if (pathArg) {
7287
- const abs = resolve7(process.cwd(), pathArg);
7288
- if (existsSync19(abs) && statSync4(abs).isDirectory())
7633
+ const abs = resolve8(process.cwd(), pathArg);
7634
+ if (existsSync20(abs) && statSync5(abs).isDirectory())
7289
7635
  root = abs;
7290
- else if (abs.endsWith(".scene.ts") && existsSync19(abs))
7636
+ else if (abs.endsWith(".scene.ts") && existsSync20(abs))
7291
7637
  root = dirname13(abs);
7292
7638
  else
7293
7639
  throw new CliError(`No such folder or .scene.ts: ${pathArg}`);
7294
7640
  }
7295
7641
  }
7296
- return { root, manifest, name: manifest?.name ?? basename11(root) };
7642
+ return { root, manifest, name: manifest?.name ?? basename12(root) };
7297
7643
  };
7298
7644
  var writeMcpConfig2 = (root, port) => {
7299
- const path = join21(root, ".mcp.json");
7645
+ const path = join22(root, ".mcp.json");
7300
7646
  const entry = { type: "http", url: `http://localhost:${port}/mcp` };
7301
7647
  let config = {};
7302
- if (existsSync19(path)) {
7648
+ if (existsSync20(path)) {
7303
7649
  try {
7304
7650
  config = JSON.parse(readFileSync20(path, "utf8"));
7305
7651
  } catch {
@@ -7320,11 +7666,11 @@ var writeMcpConfig2 = (root, port) => {
7320
7666
  };
7321
7667
  var countScenes = (dir) => {
7322
7668
  let n = 0;
7323
- for (const name of readdirSync8(dir)) {
7669
+ for (const name of readdirSync9(dir)) {
7324
7670
  if (name === "node_modules" || name.startsWith("."))
7325
7671
  continue;
7326
- const full = join21(dir, name);
7327
- if (statSync4(full).isDirectory())
7672
+ const full = join22(dir, name);
7673
+ if (statSync5(full).isDirectory())
7328
7674
  n += countScenes(full);
7329
7675
  else if (name.endsWith(".scene.ts"))
7330
7676
  n++;
@@ -7394,8 +7740,8 @@ var status = async (_args) => {
7394
7740
  };
7395
7741
 
7396
7742
  // 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";
7743
+ import { existsSync as existsSync21, readFileSync as readFileSync21, readdirSync as readdirSync10 } from "node:fs";
7744
+ import { dirname as dirname14, resolve as resolve9 } from "node:path";
7399
7745
  var deriveDesignFlows = (meta, entryScreen) => {
7400
7746
  const edges = meta.edges ?? [];
7401
7747
  const isState = (id) => id.includes("@");
@@ -7481,34 +7827,34 @@ var readFlowFile = (abs, displayName) => {
7481
7827
  var test = async (args) => {
7482
7828
  const { js, warnings, useLocalAssets, root } = await compileHeadlessBundle(args);
7483
7829
  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");
7830
+ const testsDir = resolve9(root, flagStr(args, "dir") ?? "tests");
7831
+ const designMetaPath = resolve9(root, flagStr(args, "design-dir") ?? "design", "meta.json");
7486
7832
  const asJson = flagBool(args, "json");
7487
7833
  const out = asJson ? logErr : log;
7488
7834
  const cases = [];
7489
7835
  if (args._.length > 0) {
7490
7836
  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));
7837
+ const candidates = [resolve9(given), resolve9(testsDir, given), resolve9(testsDir, `${given}.flow.json`)];
7838
+ const abs = candidates.find((p) => existsSync21(p));
7493
7839
  if (!abs)
7494
7840
  throw new CliError(`No such test: "${given}" (looked in ${testsDir}).`);
7495
7841
  cases.push(readFlowFile(abs, given));
7496
7842
  }
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));
7843
+ } else if (existsSync21(testsDir)) {
7844
+ for (const f2 of readdirSync10(testsDir).filter((f3) => f3.endsWith(".flow.json")).sort()) {
7845
+ cases.push(readFlowFile(resolve9(testsDir, f2), f2));
7500
7846
  }
7501
7847
  }
7502
7848
  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;
7849
+ const defaultPack = resolve9(testsDir, "fixtures.json");
7850
+ const flowFixtures = fixturesFlag !== undefined ? resolveFixtures(fixturesFlag, ".") : existsSync21(defaultPack) ? resolveFixtures("fixtures.json", testsDir) : undefined;
7505
7851
  const flowsOnly = flagBool(args, "flows");
7506
7852
  const wantFlows = !flagBool(args, "no-flows") && args._.length === 0;
7507
7853
  if (flowsOnly)
7508
7854
  cases.length = 0;
7509
7855
  let skipped = [];
7510
7856
  let entryScreen = null;
7511
- if ((wantFlows || flowsOnly) && existsSync20(designMetaPath)) {
7857
+ if ((wantFlows || flowsOnly) && existsSync21(designMetaPath)) {
7512
7858
  let meta;
7513
7859
  try {
7514
7860
  meta = JSON.parse(readFileSync21(designMetaPath, "utf8"));
@@ -7543,7 +7889,7 @@ var test = async (args) => {
7543
7889
  }, timeoutMs);
7544
7890
  killer.unref?.();
7545
7891
  const onConsole = flagBool(args, "logs") ? (level, text) => logErr(`${c.dim(`[${level}]`)} ${text}`) : undefined;
7546
- const artifactsRoot = resolve8(root, flagStr(args, "artifacts") ?? resolve8(testsDir, ".artifacts"));
7892
+ const artifactsRoot = resolve9(root, flagStr(args, "artifacts") ?? resolve9(testsDir, ".artifacts"));
7547
7893
  const results = [];
7548
7894
  for (const kase of cases) {
7549
7895
  const flagSettle = flagStr(args, "settle");
@@ -7561,14 +7907,14 @@ var test = async (args) => {
7561
7907
  onConsole
7562
7908
  });
7563
7909
  try {
7564
- const io = fileScenarioIO(resolve8(artifactsRoot, slug3(kase.name)));
7910
+ const io = fileScenarioIO(resolve9(artifactsRoot, slug3(kase.name)));
7565
7911
  const outcome = await renderer.runScenario(session, steps, io);
7566
7912
  result = {
7567
7913
  name: kase.name,
7568
7914
  kind: kase.kind,
7569
7915
  ok: outcome.ok,
7570
7916
  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))
7917
+ artifacts: outcome.failureArtifacts.map((p) => resolve9(artifactsRoot, slug3(kase.name), p))
7572
7918
  };
7573
7919
  } finally {
7574
7920
  session.dispose();
@@ -7624,23 +7970,86 @@ var test = async (args) => {
7624
7970
  process.exit(failed > 0 ? 1 : 0);
7625
7971
  };
7626
7972
 
7973
+ // src/commands/types.ts
7974
+ import { resolve as resolve10 } from "node:path";
7975
+ var label = (bundle) => `${Object.keys(bundle).length} file(s)`;
7976
+ var types = async (args) => {
7977
+ const dirArg = args._[0];
7978
+ const root = dirArg ? resolve10(dirArg) : (() => {
7979
+ try {
7980
+ return findProjectRoot(process.cwd());
7981
+ } catch {
7982
+ return process.cwd();
7983
+ }
7984
+ })();
7985
+ const useLocal = flagBool(args, "local");
7986
+ const checkOnly = flagBool(args, "check");
7987
+ const local = readLocalTypeBundle();
7988
+ let bundle = null;
7989
+ let source = "";
7990
+ if (useLocal) {
7991
+ if (!local)
7992
+ throw new CliError("This CLI ships no vendored type bundle — drop --local to fetch them from the server.");
7993
+ bundle = local;
7994
+ source = "the CLI's bundled SDK";
7995
+ } else {
7996
+ const apiUrl = normalizeApiUrl(flagStr(args, "api") ?? loadConfig().apiUrl ?? DEFAULT_API);
7997
+ try {
7998
+ bundle = await getTypes(apiUrl);
7999
+ source = apiUrl;
8000
+ } catch (e) {
8001
+ if (!local || checkOnly)
8002
+ throw e;
8003
+ warn(`${e.message}`);
8004
+ bundle = local;
8005
+ source = "the CLI's bundled SDK (server unreachable)";
8006
+ }
8007
+ }
8008
+ log(`${c.green("source")} ${source} — ${label(bundle)}`);
8009
+ const onDisk = readProjectTypeBundle(root);
8010
+ const same = bundleHash(onDisk) === bundleHash(bundle);
8011
+ if (checkOnly) {
8012
+ if (Object.keys(onDisk).length === 0)
8013
+ throw new CliError(`No types in ${LECODES_DIR}/types/ — run \`lecodes types\`.`);
8014
+ if (!same)
8015
+ throw new CliError(`Types are out of date (${label(onDisk)} on disk) — run \`lecodes types\`.`);
8016
+ success(`Types are up to date (${label(onDisk)}).`);
8017
+ return;
8018
+ }
8019
+ const stats = writeTypeBundle(root, bundle);
8020
+ writeTsconfig(root);
8021
+ const changes = [
8022
+ stats.added ? `${stats.added} added` : null,
8023
+ stats.updated ? `${stats.updated} updated` : null,
8024
+ stats.removed ? `${stats.removed} removed` : null
8025
+ ].filter(Boolean);
8026
+ log(`${c.green("written")} ${LECODES_DIR}/types/ + tsconfig.json ${c.dim(changes.length ? `(${changes.join(", ")})` : "(already current)")}`);
8027
+ if (!useLocal && local && bundleHash(local) !== bundleHash(bundle)) {
8028
+ 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.");
8029
+ }
8030
+ log("");
8031
+ success(stats.added + stats.updated + stats.removed > 0 ? "Types synced." : "Types already current.");
8032
+ if (!dirArg)
8033
+ note("Reload the TypeScript server in your editor to pick them up.");
8034
+ };
8035
+
7627
8036
  // src/index.ts
7628
- import { existsSync as existsSync22, readFileSync as readFileSync23, realpathSync as realpathSync2 } from "node:fs";
8037
+ import { existsSync as existsSync23, readFileSync as readFileSync23, realpathSync as realpathSync2 } from "node:fs";
7629
8038
  import { createRequire as createRequire4 } from "node:module";
7630
- import { delimiter as delimiter2, dirname as dirname16, join as join23 } from "node:path";
8039
+ import { delimiter as delimiter2, dirname as dirname16, join as join24 } from "node:path";
7631
8040
 
7632
8041
  // src/commands/install.ts
7633
8042
  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";
8043
+ 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
8044
  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";
8045
+ import { basename as basename13, delimiter, dirname as dirname15, join as join23, resolve as resolve11, sep as sep2 } from "node:path";
7637
8046
  var isWin = process.platform === "win32";
7638
8047
  var HOME = homedir3();
7639
8048
  var safeRealpath = (p) => {
7640
8049
  try {
7641
8050
  return realpathSync(p);
7642
8051
  } catch {
7643
- return resolve9(p);
8052
+ return resolve11(p);
7644
8053
  }
7645
8054
  };
7646
8055
  var onPath = (dir) => {
@@ -7657,12 +8066,12 @@ var onPath = (dir) => {
7657
8066
  };
7658
8067
  var install = async (args) => {
7659
8068
  const bundle = dirname15(process.execPath);
7660
- const exeName = basename12(process.execPath);
8069
+ const exeName = basename13(process.execPath);
7661
8070
  const chiselName = isWin ? "chisel.exe" : "chisel";
7662
- if (!existsSync21(join22(bundle, "runtime")) || !existsSync21(join22(bundle, chiselName))) {
8071
+ if (!existsSync22(join23(bundle, "runtime")) || !existsSync22(join23(bundle, chiselName))) {
7663
8072
  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
8073
  }
7665
- const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync21(join22(bundle, f2)));
8074
+ const companions = [exeName, chiselName, "runtime", "README.txt"].filter((f2) => existsSync22(join23(bundle, f2)));
7666
8075
  if (isWin)
7667
8076
  return installWindows(args, bundle, companions);
7668
8077
  return installUnix(args, bundle, companions);
@@ -7672,9 +8081,9 @@ var copyBundle = (bundle, target, companions) => {
7672
8081
  if (safeRealpath(target) === safeRealpath(bundle))
7673
8082
  return;
7674
8083
  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 });
8084
+ const dest = join23(target, f2);
8085
+ rmSync4(dest, { recursive: true, force: true });
8086
+ cpSync2(join23(bundle, f2), dest, { recursive: true });
7678
8087
  }
7679
8088
  };
7680
8089
  var permError = (e, dir, system) => {
@@ -7686,41 +8095,41 @@ var permError = (e, dir, system) => {
7686
8095
  };
7687
8096
  var installUnix = (args, bundle, companions) => {
7688
8097
  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");
8098
+ const libDir = resolve11(flagStr(args, "dir") ?? (system ? "/usr/local/lib/lecodes-cli" : join23(HOME, ".local", "lib", "lecodes-cli")));
8099
+ const binDir = resolve11(flagStr(args, "bin") ?? (system ? "/usr/local/bin" : join23(HOME, ".local", "bin")));
8100
+ const link2 = join23(binDir, "lecodes");
7692
8101
  try {
7693
8102
  copyBundle(bundle, libDir, companions);
7694
- chmodSync(join22(libDir, "lecodes"), 493);
7695
- chmodSync(join22(libDir, "chisel"), 493);
8103
+ chmodSync(join23(libDir, "lecodes"), 493);
8104
+ chmodSync(join23(libDir, "chisel"), 493);
7696
8105
  } catch (e) {
7697
8106
  throw permError(e, libDir, system);
7698
8107
  }
7699
8108
  try {
7700
8109
  mkdirSync15(binDir, { recursive: true });
7701
- rmSync3(link, { force: true });
7702
- symlinkSync(join22(libDir, "lecodes"), link);
8110
+ rmSync4(link2, { force: true });
8111
+ symlinkSync(join23(libDir, "lecodes"), link2);
7703
8112
  } catch (e) {
7704
8113
  throw permError(e, binDir, system);
7705
8114
  }
7706
8115
  success(`Installed to ${c.bold(libDir)}`);
7707
- log(` ${c.dim(link + " → " + join22(libDir, "lecodes"))}`);
8116
+ log(` ${c.dim(link2 + " → " + join23(libDir, "lecodes"))}`);
7708
8117
  finishPathUnix(args, binDir);
7709
8118
  };
7710
8119
  var shellRc = () => {
7711
- const shell = basename12(process.env.SHELL ?? "");
8120
+ const shell = basename13(process.env.SHELL ?? "");
7712
8121
  if (shell === "zsh")
7713
- return { file: join22(HOME, ".zshrc"), name: "~/.zshrc" };
8122
+ return { file: join23(HOME, ".zshrc"), name: "~/.zshrc" };
7714
8123
  if (shell === "bash")
7715
- return { file: join22(HOME, ".bashrc"), name: "~/.bashrc" };
7716
- return { file: join22(HOME, ".profile"), name: "~/.profile" };
8124
+ return { file: join23(HOME, ".bashrc"), name: "~/.bashrc" };
8125
+ return { file: join23(HOME, ".profile"), name: "~/.profile" };
7717
8126
  };
7718
8127
  var PATH_MARKER = "# Added by `lecodes install`";
7719
8128
  var appendPathToShellRc = (binDir) => {
7720
8129
  const { file, name } = shellRc();
7721
8130
  const line = `export PATH="${binDir}:$PATH"`;
7722
8131
  try {
7723
- const existing = existsSync21(file) ? readFileSync22(file, "utf8") : "";
8132
+ const existing = existsSync22(file) ? readFileSync22(file, "utf8") : "";
7724
8133
  if (existing.includes(line))
7725
8134
  return name;
7726
8135
  appendFileSync2(file, `${existing && !existing.endsWith(`
@@ -7758,7 +8167,7 @@ var finishPathUnix = (args, binDir) => {
7758
8167
  log(` • or install system-wide (no PATH edit): ${c.bold("sudo lecodes install --system")}`);
7759
8168
  };
7760
8169
  var installWindows = (args, bundle, companions) => {
7761
- const target = resolve9(flagStr(args, "dir") ?? join22(process.env.LOCALAPPDATA ?? join22(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
8170
+ const target = resolve11(flagStr(args, "dir") ?? join23(process.env.LOCALAPPDATA ?? join23(HOME, "AppData", "Local"), "Programs", "lecodes-cli"));
7762
8171
  try {
7763
8172
  copyBundle(bundle, target, companions);
7764
8173
  } catch (e) {
@@ -7797,7 +8206,7 @@ var addToUserPathWindows = (dir) => {
7797
8206
  var STANDALONE = (() => {
7798
8207
  const dir = dirname16(process.execPath);
7799
8208
  const chisel = process.platform === "win32" ? "chisel.exe" : "chisel";
7800
- return existsSync22(join23(dir, "runtime")) && existsSync22(join23(dir, chisel));
8209
+ return existsSync23(join24(dir, "runtime")) && existsSync23(join24(dir, chisel));
7801
8210
  })();
7802
8211
  var INSTALLED = STANDALONE && (() => {
7803
8212
  const exe = process.platform === "win32" ? "lecodes.exe" : "lecodes";
@@ -7812,8 +8221,8 @@ var INSTALLED = STANDALONE && (() => {
7812
8221
  if (!d)
7813
8222
  return false;
7814
8223
  try {
7815
- const cand = join23(d, exe);
7816
- return existsSync22(cand) && realpathSync2(cand) === self;
8224
+ const cand = join24(d, exe);
8225
+ return existsSync23(cand) && realpathSync2(cand) === self;
7817
8226
  } catch {
7818
8227
  return false;
7819
8228
  }
@@ -7844,6 +8253,19 @@ ${c.bold("Commands:")}
7844
8253
  --team <uuid> create it inside a team
7845
8254
  clone <uuid|url> [dir] Download a project's files + types into a new folder
7846
8255
  --no-types skip the .d.ts / tsconfig generation
8256
+ link [dir] Connect an existing local folder (e.g. one made by 'lecodes
8257
+ init') to the platform — the reverse of clone. Creates a
8258
+ project on the server, writes .lecodes/manifest.json and
8259
+ refreshes the types; nothing is uploaded until you push
8260
+ --name <s> project name (default: folder name)
8261
+ --team <uuid> create it inside a team
8262
+ --project <uuid|url> adopt an EXISTING project instead —
8263
+ prints what your next push would change
8264
+ on its server tree, and confirms first
8265
+ --push [-m <msg>] push right after linking (--publish to
8266
+ deploy it live in the same step)
8267
+ --force re-link a folder that's already linked
8268
+ --no-types skip the .d.ts / tsconfig refresh
7847
8269
  status Show local changes vs the last sync
7848
8270
  diff [path…] Show a unified diff of local vs the current server state
7849
8271
  --name-only list changed paths + status only
@@ -7907,6 +8329,13 @@ ${c.bold("Commands:")}
7907
8329
  --artifacts <dir> failure-artifact folder override
7908
8330
  (+ the render timing flags: --width/--height, --settle,
7909
8331
  --device, --safe-area, --wait-network, --logs, --entry)
8332
+ types [dir] Refresh the IDE types (.lecodes/types/ + tsconfig.json) — by
8333
+ default from the server, so a project scaffolded offline picks
8334
+ up the SDK the platform actually runs
8335
+ --local use the SDK bundled in this CLI instead
8336
+ --check report drift only, don't write (non-zero exit)
8337
+ --api <url> server to fetch from (default: the logged-in
8338
+ one, else https://le.codes)
7910
8339
  design [serve] LeCodes Design — a live canvas of screens for AI-driven prototyping
7911
8340
  (needs the optional 'lecodes-design' package; works inside a
7912
8341
  cloned project or standalone in any folder)
@@ -7933,6 +8362,9 @@ ${c.bold("Commands:")}
7933
8362
  --variant <v> SDK variant: auto (default — sync picks the
7934
8363
  smallest of core/2d/3d/full covering what
7935
8364
  the bundle uses), or one of those to pin
8365
+ --sdk <v> native SDK release (default: the newest one,
8366
+ pinned in app.json ios.sdk); 'latest' floats
8367
+ it — every sync re-resolves
7936
8368
  app sync Make the shell match the project: compile the bundle + embed
7937
8369
  its assets into ios/App/Resources/ (offline by default, also
7938
8370
  for server projects; app.json "resources": {"remote": [globs]}
@@ -7941,6 +8373,13 @@ ${c.bold("Commands:")}
7941
8373
  plugins (pinned in plugins.lock.json), regenerate the
7942
8374
  CLI-owned shell files. app.json "update": true (or
7943
8375
  {"url": …}) enables native OTA JS updates at launch
8376
+ app update Move the shell's pins forward — app.json ios.sdk to the newest
8377
+ lecodes-ios-sdk release, plugins.lock.json to each plugin's
8378
+ newest tag — then re-sync
8379
+ --sdk <v|latest> pin that release instead of the newest
8380
+ --check report available updates, write nothing
8381
+ --no-plugins only move the SDK pin
8382
+ --no-sync update the pins without re-syncing
7944
8383
  app plugin <list|add|remove> Manage native plugins in app.json (then run 'lecodes app sync')
7945
8384
  add takes a known id (camera, qr-scanner) or github:owner/repo[@tag]
7946
8385
  app open [ios] Open the native shell in its IDE (Xcode; Android Studio later)
@@ -7955,7 +8394,7 @@ ${c.bold("Commands:")}
7955
8394
  ASC API key; stored in ~/.lecodes/asc.json
7956
8395
 
7957
8396
  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 };
8397
+ var commands = { init: init2, login, create, clone, link, status, diff, pull, push, compile: compile2, preview: previewLegacy, render, test, types, design, scene, dev, app };
7959
8398
  if (STANDALONE)
7960
8399
  commands.install = install;
7961
8400
  var readVersion = () => package_default.version ?? "unknown";
@@ -7969,8 +8408,8 @@ var peerVersion = (name, probe) => {
7969
8408
  const require2 = createRequire4(import.meta.url);
7970
8409
  let dir = dirname16(require2.resolve(probe));
7971
8410
  for (let depth = 0;depth < 16; depth++) {
7972
- const manifest = join23(dir, "package.json");
7973
- if (existsSync22(manifest)) {
8411
+ const manifest = join24(dir, "package.json");
8412
+ if (existsSync23(manifest)) {
7974
8413
  const parsed = JSON.parse(readFileSync23(manifest, "utf8"));
7975
8414
  if (parsed.name === name)
7976
8415
  return parsed.version ?? "unknown";
@@ -7989,9 +8428,9 @@ var printVersion = () => {
7989
8428
  const version = peerVersion(name, probe);
7990
8429
  rows.push([name, version ?? c.dim("not installed")]);
7991
8430
  }
7992
- const width = Math.max(...rows.map(([label]) => label.length));
7993
- for (const [label, value] of rows)
7994
- log(`${label.padEnd(width)} ${value}`);
8431
+ const width = Math.max(...rows.map(([label2]) => label2.length));
8432
+ for (const [label2, value] of rows)
8433
+ log(`${label2.padEnd(width)} ${value}`);
7995
8434
  };
7996
8435
  var main = async () => {
7997
8436
  const argv = process.argv.slice(2);