lecodes-cli 2.2.11 → 2.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/args.ts CHANGED
@@ -3,7 +3,10 @@
3
3
  * --key value --key=value --flag -k value -k positionals anywhere
4
4
  * A token that starts with "-" followed by a digit (or ".5") is a negative NUMBER (or a vector
5
5
  * starting with one: `--look-at -0.15,1,2`), never the next flag. A repeated flag keeps the LAST
6
- * value (list-valued flags take commas). `--` ends flag parsing.
6
+ * value (list-valued flags take commas). `--` ends flag parsing. A flag the command declares as
7
+ * BOOLEAN never takes the next token as its value (`lecodes test --lite login` is the flag and
8
+ * the positional "login" — it used to read "login" as the flag's value, run every test headless
9
+ * and say nothing); only an explicit `true` / `false` after it, or `--flag=false`, is its value.
7
10
  */
8
11
 
9
12
  export type RawFlags = Record<string, string | boolean>
@@ -12,7 +15,7 @@ export type Parsed = { positional: string[], flags: RawFlags }
12
15
  const isNegativeNumber = (s: string): boolean => /^-(\d|\.\d)/.test(s)
13
16
  const isValue = (s: string): boolean => !s.startsWith("-") || isNegativeNumber(s)
14
17
 
15
- export const parseArgv = (argv: string[]): Parsed => {
18
+ export const parseArgv = (argv: string[], booleans: ReadonlySet<string> = new Set()): Parsed => {
16
19
  const out: Parsed = { positional: [], flags: {} }
17
20
  for (let i = 0; i < argv.length; i++) {
18
21
  const arg = argv[i]
@@ -25,7 +28,11 @@ export const parseArgv = (argv: string[]): Parsed => {
25
28
  }
26
29
  const key = arg.slice(long ? 2 : 1)
27
30
  const next = argv[i + 1]
28
- if (next !== undefined && isValue(next)) { out.flags[key] = next; i++ }
31
+ if (booleans.has(key)) {
32
+ if (next === "true" || next === "false") { out.flags[key] = next; i++ }
33
+ else out.flags[key] = true
34
+ }
35
+ else if (next !== undefined && isValue(next)) { out.flags[key] = next; i++ }
29
36
  else out.flags[key] = true
30
37
  }
31
38
  return out
@@ -105,6 +105,13 @@ export const readFlags = <F extends Flags>(spec: F, raw: RawFlags, path: string[
105
105
  return out as FlagValues<F>
106
106
  }
107
107
 
108
+ /** The names (and aliases) of a spec's boolean flags: what the argv grammar must not read a value for. */
109
+ export const booleanFlagNames = (spec: Flags): Set<string> => {
110
+ const names = new Set<string>()
111
+ for (const [name, s] of Object.entries(spec)) if (s.type === "boolean") { names.add(name); if (s.alias) names.add(s.alias) }
112
+ return names
113
+ }
114
+
108
115
  /** Flags on the line that no spec names — reported as a warning by the runner. */
109
116
  export const unknownFlags = (spec: Flags, raw: RawFlags): string[] => {
110
117
  const known = new Set<string>()
package/src/cli/run.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { booleanFlagNames } from "./command"
1
2
  import { parseArgv } from "./args"
2
3
  import { readFlags, unknownFlags, type Command, type Context } from "./command"
3
4
  import { CliError, UsageError } from "./errors"
@@ -82,8 +83,8 @@ export const runCli = async (cli: Cli, argv: string[]): Promise<void> => {
82
83
  return
83
84
  }
84
85
 
85
- const parsed = parseArgv(rest)
86
86
  const spec = node.flags ?? {}
87
+ const parsed = parseArgv(rest, booleanFlagNames(spec))
87
88
  if (!node.passthrough) {
88
89
  const unknown = unknownFlags(spec, parsed.flags)
89
90
  if (unknown.length) warnErr(`${unknown.map(f => `--${f}`).join(", ")}: not an option of \`lecodes ${path.join(" ")}\` (see --help)`)
@@ -10,7 +10,7 @@ import { upsertDotEnv } from "../../project/env"
10
10
  import {
11
11
  type AppConfig, ANDROID_SDK_VARIANTS, type AndroidSdkVariant, VERSION_RE, androidSdkArtifact, androidVariantEngines,
12
12
  childEnv, cmpVersion, ensureShellIgnored, fetchLatestSdkVersion,
13
- projectRoot, readJson, resolvePlugin, resolveSdkVersion, resolveVariant, runTool, sdkSatisfies, slugify,
13
+ projectRoot, readAppConfigAt, readJson, resolvePlugin, resolveSdkVersion, resolveVariant, runTool, sdkSatisfies, slugify,
14
14
  writeJson, type LockEntry, resolveUpdateUrl, urlSchemeOf,
15
15
  } from "./shared"
16
16
  import { loadIconMaster, restampManifestIcon, writeAndroidIcons } from "./icon"
@@ -104,7 +104,7 @@ export const initAndroid = async (opts: AndroidInitOptions) => {
104
104
  throw new CliError("android/ already exists — run `lecodes app sync` to update it.")
105
105
  }
106
106
 
107
- const existing = readJson<AppConfig>(join(root, "app.json")) ?? {}
107
+ const existing = readAppConfigAt(root) ?? {}
108
108
 
109
109
  const variant = opts.variant ?? existing.android?.variant ?? "auto"
110
110
  if (variant !== "auto" && !ANDROID_SDK_VARIANTS.includes(variant as AndroidSdkVariant)) {
@@ -249,7 +249,7 @@ export const syncAndroid = async (root: string, config: AppConfig, prior: SyncAr
249
249
  throw new CliError(`app.json android.variant must be "auto" or one of ${ANDROID_SDK_VARIANTS.join(", ")} (got "${requestedVariant}").`)
250
250
  }
251
251
  // vulkan is full's engines on another backend: the coverage check runs as full, the artifact is its own.
252
- const { variant: covered, detected } = resolveVariant(
252
+ const { variant: covered, why } = resolveVariant(
253
253
  requestedVariant === "auto" ? "auto" : androidVariantEngines(requestedVariant as AndroidSdkVariant), bundleJs, "android.variant")
254
254
  const variant: AndroidSdkVariant = requestedVariant === "vulkan" ? "vulkan" : covered
255
255
  let sdkArtifact = androidSdkArtifact(variant, sdkVersion)
@@ -257,10 +257,7 @@ export const syncAndroid = async (root: string, config: AppConfig, prior: SyncAr
257
257
  warn(`lecodes-android-sdk ${sdkVersion} predates the variant matrix (${ANDROID_SDK_VARIANTS_MIN}) — linking the full lecodes-sdk AAR; run \`lecodes app update\` to pick up variant builds.`)
258
258
  sdkArtifact = "lecodes-sdk"
259
259
  }
260
- const WHY: Record<string, string> = {
261
- full: "3D + 2D engines in use", "3d": "3D in use, no 2D", "2d": "2D in use, no 3D", core: "no 3D/2D engine use detected",
262
- }
263
- log(`${c.green("variant")} ${variant} → ${sdkArtifact}${requestedVariant === "auto" ? c.dim(` (auto: ${WHY[detected]})`) : c.dim(" (forced by android.variant)")}`)
260
+ log(`${c.green("variant")} ${variant} → ${sdkArtifact}${requestedVariant === "auto" ? c.dim(` (auto: ${why})`) : c.dim(" (forced by android.variant)")}`)
264
261
 
265
262
  // ---- plugins: vendor sources as Gradle modules, generate their build files, pin the lock ----
266
263
  const lockPath = join(root, "plugins.lock.json")
@@ -532,7 +529,7 @@ const androidBuildFlags = {
532
529
  /** `lecodes app build android` — sync, Gradle, then the artifacts copied under dist/. */
533
530
  const buildAndroid = async (flags: FlagValues<typeof androidBuildFlags>): Promise<void> => {
534
531
  const root = projectRoot()
535
- const config = readJson<AppConfig>(join(root, "app.json"))
532
+ const config = readAppConfigAt(root)
536
533
  const androidDir = join(root, "android")
537
534
  if (!config?.android || !existsSync(androidDir)) {
538
535
  throw new CliError("No Android shell here — run `lecodes app init android` first.")
@@ -779,7 +776,7 @@ const writeSigningConfig = (root: string, config: AppConfig, storeFile: string,
779
776
  }
780
777
 
781
778
  const configPath = join(root, "app.json")
782
- const fresh = readJson<AppConfig>(configPath) ?? config
779
+ const fresh = readAppConfigAt(root) ?? config
783
780
  fresh.android = { ...(fresh.android ?? {}), signing: { storeFile: envPath, keyAlias: alias } }
784
781
  writeJson(configPath, fresh)
785
782
  log(`${c.green("written")} app.json (android.signing — metadata only, no passwords)`)
@@ -799,7 +796,7 @@ const PLAY_APP_SIGNING_NOTE = "With Play App Signing (default for new Play apps)
799
796
  /** Every keystore subcommand starts from the project + its app.json (which may not exist yet). */
800
797
  const keystoreProject = (): { root: string, config: AppConfig } => {
801
798
  const root = projectRoot()
802
- return { root, config: readJson<AppConfig>(join(root, "app.json")) ?? {} }
799
+ return { root, config: readAppConfigAt(root) ?? {} }
803
800
  }
804
801
 
805
802
  const passwordFlag = { password: str("keystore password (else asked on the terminal)", { value: "<pw>" }) }
@@ -5,7 +5,7 @@ import { buildBundle } from "../compile"
5
5
  import { resolveDesktopExe, selectedDesktopVersion } from "../../hosts/desktopRenderer"
6
6
  import { CliError, bool, c, defineCommand, log, note, num, str, success, warn, type FlagValues } from "../../cli"
7
7
  import { bundleFlags, bundleOptions } from "../shared"
8
- import { desktopPluginLibraries, projectRoot, readJson, resolveDesktopRenderer, writeJson, type AppConfig } from "./shared"
8
+ import { desktopPluginLibraries, projectRoot, readAppConfigAt, readJson, resolveDesktopRenderer, writeJson, type AppConfig } from "./shared"
9
9
  import { loadIconMaster } from "./icon"
10
10
  import { buildMacApp, macHostProvidedNames } from "./desktopMac"
11
11
 
@@ -41,7 +41,7 @@ export interface DesktopInitOptions { name?: string, fullscreen?: boolean }
41
41
  * `lecodes init --desktop` for existing projects). Purely additive; re-running keeps edits. */
42
42
  export const initDesktop = async (opts: DesktopInitOptions): Promise<void> => {
43
43
  const root = projectRoot()
44
- const existing = readJson<AppConfig>(join(root, "app.json")) ?? {}
44
+ const existing = readAppConfigAt(root) ?? {}
45
45
  const name = opts.name ?? existing.name ?? basename(root)
46
46
  const window = {
47
47
  width: 1280, height: 800, minWidth: 640, minHeight: 480,
@@ -82,7 +82,7 @@ const desktopBuildFlags = {
82
82
 
83
83
  const buildDesktop = async (flags: FlagValues<typeof desktopBuildFlags>): Promise<void> => {
84
84
  const root = projectRoot()
85
- const config = readJson<AppConfig>(join(root, "app.json")) ?? {}
85
+ const config = readAppConfigAt(root) ?? {}
86
86
  const name = (config.name ?? basename(root)).trim() || "app"
87
87
  const slug = flags.slug ?? cleanFileName(name)
88
88
  const out = resolve(root, flags.out ?? join(root, "dist", slug))
@@ -13,7 +13,7 @@ import {
13
13
  } from "./templates/ios"
14
14
  import {
15
15
  FIRST_PARTY_PLUGINS, SDK_VARIANTS, cmpVersion, ensureShellIgnored,
16
- fetchLatestSdkVersion, isLocalPluginSpec, latestPluginTag, projectRoot, readJson, resolvePlugin, resolveSdkVersion, sdkSatisfies,
16
+ fetchLatestSdkVersion, isLocalPluginSpec, latestPluginTag, projectRoot, readAppConfigAt, readJson, resolvePlugin, resolveSdkVersion, sdkSatisfies,
17
17
  resolveVariant, runTool, slugify, writeJson, VERSION_RE, type AppConfig, type LockEntry, resolveUpdateUrl, urlSchemeOf,
18
18
  } from "./shared"
19
19
  import {
@@ -111,7 +111,7 @@ const initIos = async (opts: IosInitOptions): Promise<void> => {
111
111
  throw new CliError("ios/ already exists — run `lecodes app sync` to update it.")
112
112
  }
113
113
 
114
- const existing = readJson<AppConfig>(join(root, "app.json")) ?? {}
114
+ const existing = readAppConfigAt(root) ?? {}
115
115
  const variant = opts.variant ?? existing.ios?.variant ?? "auto"
116
116
  if (variant !== "auto" && !SDK_VARIANTS.includes(variant as SdkVariant)) {
117
117
  throw new CliError(`--variant must be auto, ${SDK_VARIANTS.join(", ")} (got "${variant}")`)
@@ -216,7 +216,7 @@ const init = defineCommand({
216
216
  * the byte-identical output. `app update` and `app build ios` run it too. */
217
217
  export const runSync = async (platformArg?: string): Promise<void> => {
218
218
  const root = projectRoot()
219
- const config = readJson<AppConfig>(join(root, "app.json"))
219
+ const config = readAppConfigAt(root)
220
220
  const platform = mobilePlatform(platformArg)
221
221
  const { hasIos, hasAndroid } = shellsOf(root, config)
222
222
  if (platform === "ios" && !hasIos) throw new CliError(NO_IOS_SHELL)
@@ -312,11 +312,8 @@ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts>
312
312
 
313
313
  // ---- variant: the smallest SDK product covering what the bundle uses (or the forced one) ----
314
314
  const bundleJs = readFileSync(join(resourcesDir, "app.js"), "utf8")
315
- const { variant, detected } = resolveVariant(requestedVariant, bundleJs)
316
- const WHY: Record<SdkVariant, string> = {
317
- core: "UI only", "2d": "uses the 2D engine", "3d": "uses the 3D engine", full: "uses the 3D + 2D engines",
318
- }
319
- log(`${c.green("variant")} ${variant}${requestedVariant === "auto" ? ` (auto: ${WHY[detected]})` : " (forced by ios.variant)"}`)
315
+ const { variant, why } = resolveVariant(requestedVariant, bundleJs)
316
+ log(`${c.green("variant")} ${variant}${requestedVariant === "auto" ? ` (auto: ${why})` : " (forced by ios.variant)"}`)
320
317
  const sdkWhy = sdkPin === "latest" ? "latest" : config.ios.sdk ? "pinned by ios.sdk" : "from the shell — set ios.sdk or run `lecodes app update`"
321
318
  log(`${c.green("sdk")} lecodes-ios-sdk ${sdkVersion}${c.dim(` (${sdkWhy})`)}`)
322
319
 
@@ -449,7 +446,7 @@ const sync = defineCommand({
449
446
  const pluginConfig = (): { root: string, configPath: string, config: AppConfig & { plugins: Record<string, string> } } => {
450
447
  const root = projectRoot()
451
448
  const configPath = join(root, "app.json")
452
- const config = readJson<AppConfig>(configPath)
449
+ const config = readAppConfigAt(root)
453
450
  if (!config) throw new CliError("No app.json here — run `lecodes app init <ios|android>` first.")
454
451
  config.plugins ??= {}
455
452
  return { root, configPath, config: config as AppConfig & { plugins: Record<string, string> } }
@@ -572,7 +569,7 @@ export interface AppUpdateOptions { platform?: string, sdk?: string, check?: boo
572
569
  * `lecodes update` runs it as its shells step. */
573
570
  export const runAppUpdate = async (opts: AppUpdateOptions = {}): Promise<void> => {
574
571
  const root = projectRoot()
575
- const config = readJson<AppConfig>(join(root, "app.json"))
572
+ const config = readAppConfigAt(root)
576
573
  const platform = mobilePlatform(opts.platform)
577
574
  const { hasIos, hasAndroid } = shellsOf(root, config)
578
575
  if (platform === "ios" && !hasIos) throw new CliError(NO_IOS_SHELL)
@@ -756,7 +753,7 @@ const doctor = defineCommand({
756
753
  examples: ["lecodes app doctor"],
757
754
  run: async () => {
758
755
  const root = projectRoot()
759
- const config = readJson<AppConfig>(join(root, "app.json"))
756
+ const config = readAppConfigAt(root)
760
757
  const { hasIos, hasAndroid } = shellsOf(root, config)
761
758
  if (!hasIos && !hasAndroid) throw new CliError(NO_IOS_SHELL)
762
759
  let failed = 0
@@ -805,7 +802,7 @@ const exportOptionsPlist = (method: string, teamId: string, destination?: "uploa
805
802
 
806
803
  const requireShell = (): { root: string, config: AppConfig } => {
807
804
  const root = projectRoot()
808
- const config = readJson<AppConfig>(join(root, "app.json"))
805
+ const config = readAppConfigAt(root)
809
806
  if (!config?.ios || !existsSync(join(root, "ios"))) throw new CliError(NO_IOS_SHELL)
810
807
  return { root, config }
811
808
  }
@@ -905,7 +902,7 @@ const iosUpload = defineCommand({
905
902
 
906
903
  // A successful upload consumes this build number — bump for the next one.
907
904
  const configPath = join(root, "app.json")
908
- const fresh = readJson<AppConfig>(configPath)!
905
+ const fresh = readAppConfigAt(root)!
909
906
  fresh.ios!.buildNumber = (fresh.ios!.buildNumber ?? 1) + 1
910
907
  writeJson(configPath, fresh)
911
908
  success(`Uploaded build ${(fresh.ios!.buildNumber ?? 2) - 1} — check App Store Connect / TestFlight once processing finishes.`)
@@ -7,6 +7,7 @@ import { loadConfig, requireApiUrl } from "../../platform/config"
7
7
  import { DEFAULT_IGNORE_FILE } from "../../project/ignore"
8
8
  import { CliError, c, log, note, warn } from "../../cli"
9
9
  import { pascal, readManifest as readPluginManifest, type IosPackage, type PluginManifest as PluginManifest2 } from "../../plugin/manifest"
10
+ import { pluginSpecsOf } from "lecodes-sdk/compile"
10
11
 
11
12
  /** The 2.x manifest (plugin/manifest.ts), plus the desktop branch the desktop flow reads: a
12
13
  * PREBUILT shared library per OS/arch behind the C ABI in hosts/desktop/plugin-sdk — another
@@ -71,7 +72,7 @@ export interface AppConfig {
71
72
  * `variant`: SDK artifact — "auto" (default: sync picks the smallest one covering the engines
72
73
  * the compiled bundle actually uses) or core|2d|3d|full to force one, like ios.variant
73
74
  * (needs lecodes-android-sdk >= 1.3.0 — older releases ship only the full AAR); "vulkan" = full
74
- * on Filament's Vulkan backend, arm64 only, never auto-picked (lecodes-android-sdk >= 2.1.0).
75
+ * on Filament's Vulkan backend, arm64 only, never auto-picked (lecodes-android-sdk >= 2.2.11).
75
76
  * `signing`: keystore METADATA only (written by `lecodes app keystore create|use`) — the
76
77
  * path + passwords live in the project .env as LECODES_ANDROID_* (or android/
77
78
  * keystore.properties for Studio-only builds), never in app.json.
@@ -216,8 +217,13 @@ export const urlSchemeOf = (config: Pick<AppConfig, "urlScheme"> | null | undefi
216
217
  /** app.json wherever a command needs the project-level desktop/window config. Tolerant of a
217
218
  * missing or broken file — standalone projects (no app.json) must keep working everywhere
218
219
  * this is consulted. */
219
- export const readAppConfigAt = (root: string): AppConfig | null =>
220
- readJson<AppConfig>(join(root, "app.json"))
220
+ export const readAppConfigAt = (root: string): AppConfig | null => {
221
+ const config = readJson<AppConfig & { plugins?: unknown }>(join(root, "app.json"))
222
+ // `"plugins"` is read in either shape (`["camera"]` or `{ "camera": "*" }`, the SDK's rule):
223
+ // every command below works on id → spec, and a list read as "no plugins" vendored nothing.
224
+ if (config && "plugins" in config) config.plugins = pluginSpecsOf(config.plugins)
225
+ return config as AppConfig | null
226
+ }
221
227
 
222
228
  /** The desktop host's own built-in fallback, mirrored where the CLI must fill a missing side. */
223
229
  export const DESKTOP_DEFAULT_WINDOW = { width: 1280, height: 720 }
@@ -361,36 +367,56 @@ export const ensureShellIgnored = (root: string, dir: string) => {
361
367
  export const SDK_VARIANTS = ["core", "2d", "3d", "full"] as const
362
368
  type SdkVariantShared = (typeof SDK_VARIANTS)[number]
363
369
 
370
+ export type EngineUse = { gl: boolean, g2: boolean, physics: boolean, net: boolean, nav: boolean, audio: boolean }
371
+
364
372
  /** Engine signals from a compiled bundle's header — the same lines buildHeader emits.
365
373
  * No `// gl: disable` line → the 3D engine is used (eagerly, or lazily via `gl: defer`);
366
- * `// 2d: enable` → the 2D engine is used. */
367
- export const detectEngines = (bundleJs: string): { gl: boolean, g2: boolean } => {
374
+ * `// 2d: enable` → the 2D engine is used; `// physics|net|nav|audio: enable` → that part of the
375
+ * game stack is called (a bundle older than those lines reads as using none). */
376
+ export const detectEngines = (bundleJs: string): EngineUse => {
368
377
  const lines: string[] = []
369
378
  for (const line of bundleJs.split("\n")) {
370
379
  if (!line.startsWith("//")) break
371
380
  lines.push(line)
372
381
  }
373
382
  const header = lines.join("\n")
374
- return { gl: !/^\/\/ gl: disable$/m.test(header), g2: /^\/\/ 2d: enable$/m.test(header) }
383
+ const on = (name: string) => new RegExp(`^// ${name}: enable$`, "m").test(header)
384
+ return { gl: !/^\/\/ gl: disable$/m.test(header), g2: on("2d"), physics: on("physics"), net: on("net"), nav: on("nav"), audio: on("audio") }
375
385
  }
376
386
 
387
+ /** What each variant carries, the switch tables of hosts/apple/build-apple.sh and
388
+ * hosts/android/build-android.ps1: `3d` = Filament alone (no Jolt, net, nav; on iOS no game
389
+ * audio); `2d` / `core` = no Filament, no Jolt, net or nav (game audio stays on iOS; Android
390
+ * links it in no variant). */
391
+ const VARIANT_LACKS: Record<SdkVariantShared, (keyof EngineUse)[]> = {
392
+ full: [],
393
+ "3d": ["g2", "physics", "net", "nav", "audio"],
394
+ "2d": ["gl", "physics", "net", "nav"],
395
+ core: ["gl", "g2", "physics", "net", "nav"],
396
+ }
397
+ const ENGINE_WORDS: Record<keyof EngineUse, string> = { gl: "3D", g2: "2D", physics: "physics", net: "multiplayer", nav: "navigation", audio: "game audio" }
398
+
399
+ /** The engines of `uses` the variant does not carry, in words ("3D + physics"). */
400
+ const lacks = (variant: SdkVariantShared, uses: EngineUse): string[] =>
401
+ VARIANT_LACKS[variant].filter((e) => uses[e]).map((e) => ENGINE_WORDS[e])
402
+
377
403
  /** The SDK product for a shell: the smallest variant covering the engines the compiled bundle
378
404
  * actually uses, unless <configKey> (ios.variant / android.variant) forces one — then warn when
379
405
  * the forced product can't run the project (missing engine = those scenes silently no-op on
380
- * device). */
381
- export const resolveVariant = (requested: string, bundleJs: string, configKey = "ios.variant"): { variant: SdkVariantShared, detected: SdkVariantShared } => {
406
+ * device, Physics.configure throws). `why` says what the auto pick rests on. */
407
+ export const resolveVariant = (requested: string, bundleJs: string, configKey = "ios.variant"): { variant: SdkVariantShared, detected: SdkVariantShared, why: string } => {
382
408
  const uses = detectEngines(bundleJs)
383
- const detected: SdkVariantShared = uses.gl && uses.g2 ? "full" : uses.gl ? "3d" : uses.g2 ? "2d" : "core"
384
- if (requested === "auto") return { variant: detected, detected }
409
+ // The smallest variant that lacks nothing the bundle uses (SDK_VARIANTS is ordered by size).
410
+ const detected = SDK_VARIANTS.find((v) => lacks(v, uses).length === 0) ?? "full"
411
+ const inUse = (Object.keys(ENGINE_WORDS) as (keyof EngineUse)[]).filter((e) => uses[e]).map((e) => ENGINE_WORDS[e])
412
+ const why = inUse.length > 0 ? `uses ${inUse.join(" + ")}` : "UI only, no engine use detected"
413
+ if (requested === "auto") return { variant: detected, detected, why }
385
414
  const variant = requested as SdkVariantShared
386
- const missing = [
387
- uses.gl && variant !== "3d" && variant !== "full" ? "3D" : null,
388
- uses.g2 && variant !== "2d" && variant !== "full" ? "2D" : null,
389
- ].filter(Boolean)
415
+ const missing = lacks(variant, uses)
390
416
  if (missing.length > 0) {
391
- warn(`project uses the ${missing.join(" + ")} engine but ${configKey} forces "${variant}" — those scenes won't run on device; remove ${configKey} to auto-select "${detected}".`)
417
+ warn(`project uses ${missing.join(" + ")} but ${configKey} forces "${variant}", which has no such engine — that part won't run on device; remove ${configKey} to auto-select "${detected}".`)
392
418
  }
393
- return { variant, detected }
419
+ return { variant, detected, why }
394
420
  }
395
421
 
396
422
  /* ------------------------------ Android: the vulkan variant ------------------------------ */
@@ -403,7 +429,7 @@ export const ANDROID_SDK_VARIANTS = [...SDK_VARIANTS, "vulkan"] as const
403
429
  export type AndroidSdkVariant = (typeof ANDROID_SDK_VARIANTS)[number]
404
430
 
405
431
  /** First lecodes-android-sdk release whose GitHub release carries lecodes-sdk-vulkan. */
406
- export const ANDROID_SDK_VULKAN_MIN = "2.1.0"
432
+ export const ANDROID_SDK_VULKAN_MIN = "2.2.11"
407
433
 
408
434
  /** Release-asset / ivy artifact name of an Android variant — full keeps the historic plain name so
409
435
  * pre-variant pins and shells keep resolving. vulkan refuses a release that has no such asset: Gradle
@@ -286,7 +286,7 @@ engines the bundle uses and cannot see those, so a project using \`Physics\`,
286
286
 
287
287
  \`android.variant: "vulkan"\` links \`lecodes-sdk-vulkan\`: the full engine set with Filament on
288
288
  Vulkan (OpenGL ES where the device has no Vulkan 1.1), \`arm64-v8a\` only — for 3D games. Never
289
- auto-selected; needs lecodes-android-sdk >= 2.1.0.
289
+ auto-selected; needs lecodes-android-sdk >= 2.2.11.
290
290
 
291
291
  ## App-local native code
292
292
 
@@ -16,8 +16,10 @@ import login from "./login"
16
16
  import navmesh from "./navmesh"
17
17
  import plugin from "./plugin"
18
18
  import pn from "./pn"
19
+ import publish from "./publish"
19
20
  import pull from "./pull"
20
21
  import push from "./push"
22
+ import releases from "./releases"
21
23
  import render from "./render"
22
24
  import scene from "./scene"
23
25
  import serve from "./serve"
@@ -33,7 +35,7 @@ import web from "./web"
33
35
  * here, in the group it belongs to (CONTRIBUTING.md). Order within a group is the help's order.
34
36
  */
35
37
  export const commandGroups = (opts: { install: boolean }): Group[] => [
36
- { title: "Project", commands: [init, create, clone, link, status, diff, pull, push, login] },
38
+ { title: "Project", commands: [init, create, clone, link, status, diff, pull, push, publish, releases, login] },
37
39
  { title: "Run & test", commands: [
38
40
  dev,
39
41
  web,
@@ -32,7 +32,7 @@ lecodes render --desktop --view iso --png iso.png # 3D: inspection camera fitt
32
32
  # { "view": "iso", "screenshot": "iso.png" } then { "view": "off" }
33
33
  lecodes render --desktop --script <file> # the same scenario on the NATIVE host (real GPU, the
34
34
  # real hit-test): the same steps, targets and expects as
35
- # headless (only expect.audio / the json output are headless-only)
35
+ # headless (only expect.audio / expect.toast / the json output are headless-only)
36
36
  lecodes render --sim --script <file> # the WINDOWLESS native host (lecodes-server): the real
37
37
  # engines, no GPU, no pixels, no UI hit-test — expect.log /
38
38
  # expect.paint only; runs on CI and beside an open desktop window
@@ -78,11 +78,12 @@ Three hosts run the same file, at a fixed 16 ms clock on all of them:
78
78
  |---|---|---|---|---|
79
79
  | headless | (none) | Bun: the runtime + UI renderer + a recording 3D core (no GPU) | names or \`{ x, y }\` | all |
80
80
  | sim | \`--sim\` | the windowless native server: real engines, no pixels, no UI | \`{ x, y }\`, straight to the scene | \`log\`, \`paint\` |
81
- | desktop | \`--desktop\` | the hidden desktop window: pixels, GL materials, the real hit-test | names or \`{ x, y }\` | all but \`audio\` |
81
+ | desktop | \`--desktop\` | the hidden desktop window: pixels, GL materials, the real hit-test | names or \`{ x, y }\` | all but \`audio\` / \`toast\` |
82
82
 
83
83
  One flow file, the same verdict on headless and desktop: names resolve the same way (\`name\` →
84
84
  exact text → substring → placeholder), every step runs (\`hover\`, \`type\`, \`fill\`, \`scroll\`,
85
- \`clip\` included) and the semantic expects read the same tree. Only \`expect.audio\` and the
85
+ \`clip\` included) and the semantic expects read the same tree. Only \`expect.audio\`,
86
+ \`expect.toast\` (the last \`toast()\` message — a toast is not in the tree) and the
86
87
  \`json\` output are the headless renderer's. On the desktop \`type\` taps the input and types into
87
88
  it (appends); the sim has no UI, so it takes coordinates and \`expect.log\` / \`expect.paint\` only.
88
89
 
@@ -0,0 +1,38 @@
1
+ import { bool, defineCommand, str } from "../cli"
2
+ import { publishProject } from "../platform/api"
3
+ import { diffLocal, hasChanges, scanProject } from "../project/localFiles"
4
+ import { runPush } from "./push"
5
+ import { qrUrl, reportRelease, withSchemaConfirm } from "./releases"
6
+ import { linkedProject } from "./shared"
7
+
8
+ /*
9
+ * `lecodes publish` — the editor's Publish button (docs/plans/publishing-plan.md). Local changes go
10
+ * up first as one checkpoint (the push, message "Release" unless -m); then the platform builds the
11
+ * head, deploys its server to production and makes the build the current release — unless the head
12
+ * is the current release already (its checkpoint, or a build to the same bytes): then nothing is
13
+ * made and the answer is `unchanged` ("Nothing changed — 1.2.4 is current", exit 0); --force makes
14
+ * one all the same (a new SDK on the build service). A schema change
15
+ * that deletes production data is the one question (withSchemaConfirm). `push --publish` is the
16
+ * same in one /sync call.
17
+ */
18
+ export default defineCommand({
19
+ name: "publish",
20
+ summary: "Release the project: push local changes, build, make it the live version",
21
+ description: "The editor's Publish button. Local changes are pushed first as a checkpoint (\"Release\", or -m). The platform builds the project, deploys its server to production and makes the build the current release at https://<handle>.le.codes — what the viewers and the store run. A project that builds to what is live already makes no release: \"Nothing changed — <version> is current\" (--force releases it all the same). A schema change that deletes production data is shown and asked about; without a terminal it needs --allow-destructive. `lecodes releases` lists the releases, `lecodes releases use <version>` rolls back.",
22
+ flags: {
23
+ message: str("checkpoint message when there are local changes (default \"Release\")", { alias: "m", value: "<text>" }),
24
+ force: bool("rebuild even when nothing changed — a new SDK on the build service"),
25
+ "allow-destructive": bool("apply a schema change that deletes production data without asking"),
26
+ },
27
+ examples: ["lecodes publish", 'lecodes publish -m "Checkout v2"', "lecodes publish --force", "lecodes publish --allow-destructive"],
28
+ run: async ({ flags }) => {
29
+ const { root, manifest, apiUrl, token } = linkedProject()
30
+ if (hasChanges(diffLocal(manifest, scanProject(root, manifest)))) await runPush({ message: flags.message || "Release" })
31
+
32
+ const release = await withSchemaConfirm(
33
+ allowDestructive => publishProject(apiUrl, token, manifest.uuid, { allowDestructive, force: flags.force }),
34
+ { allowDestructive: flags["allow-destructive"], retry: flags.force ? "lecodes publish --force" : "lecodes publish" },
35
+ )
36
+ if (release) reportRelease(release, qrUrl(apiUrl, manifest.uuid))
37
+ },
38
+ })
@@ -1,11 +1,12 @@
1
1
  import { readFileSync } from "node:fs"
2
2
  import { basename } from "node:path"
3
3
  import { CliError, bool, c, confirm, defineCommand, info, log, prompt, str, success, warn } from "../cli"
4
- import { getCommits, getProject, publishProject, sync, uploadBlob, type Project, type SyncFileInput, type SyncFolderInput } from "../platform/api"
4
+ import { getCommits, getProject, publishProject, sync, uploadBlob, type Project, type SyncFileInput, type SyncFolderInput, type SyncResponse } from "../platform/api"
5
5
  import { computeServerDiff } from "../platform/serverDiff"
6
6
  import { classifyKind, diffLocal, hasChanges, isTextKind, scanProject } from "../project/localFiles"
7
7
  import { writeManifest, type FileKind, type Manifest, type ManifestFile } from "../project/manifest"
8
8
  import { buildPaths } from "../project/materialize"
9
+ import { qrUrl, reportRelease, withSchemaConfirm } from "./releases"
9
10
  import { linkedProject } from "./shared"
10
11
 
11
12
  /*
@@ -13,8 +14,10 @@ import { linkedProject } from "./shared"
13
14
  * then POST /sync (atomic set-tree + one checkpoint). Identity is carried via assetId so renames /
14
15
  * moves keep their history; unchanged binaries are referenced by id and never re-uploaded.
15
16
  *
16
- * With --publish the push also deploys the project (compile → live bundle) in the same /sync call,
17
- * so the changes go live atomically. On a clean tree, --publish just deploys the current state.
17
+ * With --publish the push also releases the project (build → the current release) in the same /sync
18
+ * call, so the changes go live atomically. On a clean tree, --publish is `lecodes publish`.
19
+ * --force-publish is --publish that releases even a head the live release was made of
20
+ * (`lecodes publish --force`; --force itself is the push's overwrite of the server).
18
21
  *
19
22
  * It also carries the design board's queued comment writes — replies and resolutions Claude or the
20
23
  * developer wrote locally. They go AFTER the design, deliberately: a reply saying "fixed" that
@@ -24,29 +27,39 @@ import { linkedProject } from "./shared"
24
27
 
25
28
  export const pushFlags = {
26
29
  message: str("checkpoint message (asked for when omitted)", { alias: "m", value: "<text>" }),
27
- publish: bool("also deploy the project live (compile) with the push"),
30
+ publish: bool("also release the project with the push (= `lecodes publish`)"),
31
+ // (not --force: that one is the push's own, overwriting the server)
32
+ "force-publish": bool("--publish, and release even when nothing changed — a new SDK on the build service (= `lecodes publish --force`)"),
28
33
  force: bool("push even if the server moved on (shows + confirms which server files get overwritten)"),
29
34
  yes: bool("skip the --force overwrite confirmation", { alias: "y" }),
30
35
  }
31
36
 
32
- export type PushOptions = { message?: string, publish?: boolean, force?: boolean, yes?: boolean }
37
+ export type PushOptions = { message?: string, publish?: boolean, "force-publish"?: boolean, force?: boolean, yes?: boolean }
33
38
 
34
39
  export default defineCommand({
35
40
  name: "push",
36
41
  summary: "Push local changes as one new checkpoint",
37
42
  flags: pushFlags,
38
- examples: ['lecodes push -m "Tweak the menu"', "lecodes push --publish"],
43
+ examples: ['lecodes push -m "Tweak the menu"', "lecodes push --publish", "lecodes push --force-publish"],
39
44
  run: ({ flags }) => runPush(flags),
40
45
  })
41
46
 
42
47
  /** The push itself — `lecodes link --push` runs it too. */
43
48
  export const runPush = async (opts: PushOptions): Promise<void> => {
44
49
  const { root, manifest, apiUrl, token } = linkedProject()
45
- const publish = !!opts.publish
50
+ const forcePublish = !!opts["force-publish"]
51
+ const publish = !!opts.publish || forcePublish
46
52
  const scan = scanProject(root, manifest)
47
53
  const diff = diffLocal(manifest, scan)
48
54
  if (!hasChanges(diff)) {
49
- if (publish) { await publishProject(apiUrl, token, manifest.uuid); success("No file changes — published the current project state.") }
55
+ if (publish) {
56
+ info(c.dim("Working tree clean — publishing the project as it is."))
57
+ const release = await withSchemaConfirm(
58
+ allowDestructive => publishProject(apiUrl, token, manifest.uuid, { allowDestructive, force: forcePublish }),
59
+ { allowDestructive: false, retry: "lecodes publish" },
60
+ )
61
+ if (release) reportRelease(release, qrUrl(apiUrl, manifest.uuid))
62
+ }
50
63
  else info(c.dim("Working tree clean — nothing to push."))
51
64
  // Still flush: a queued answer must not be stuck behind "no files changed", which is exactly the
52
65
  // state you're in after resolving a comment about something you already pushed.
@@ -110,10 +123,10 @@ export const runPush = async (opts: PushOptions): Promise<void> => {
110
123
  const folders: SyncFolderInput[] = scan.dirs.map(path => ({ path }))
111
124
 
112
125
  if (uploaded > 0) info(c.dim(`Uploaded ${uploaded} binary file${uploaded === 1 ? "" : "s"}.`))
113
- const res = await sync(apiUrl, token, manifest.uuid, { message, files, folders, publish })
126
+ const res = await sync(apiUrl, token, manifest.uuid, { message, files, folders, publish, ...(forcePublish ? { force: true } : {}) })
114
127
  if (!res.created) {
115
128
  info(c.dim("Server reported no changes."))
116
- if (publish) reportPublish(res)
129
+ if (publish) reportPublish(res, qrUrl(apiUrl, manifest.uuid))
117
130
  return
118
131
  }
119
132
 
@@ -130,7 +143,7 @@ export const runPush = async (opts: PushOptions): Promise<void> => {
130
143
  diff.deleted.length - diff.moves.size && `${diff.deleted.length - diff.moves.size} deleted`,
131
144
  ].filter(Boolean).join(", ")
132
145
  success(`Pushed checkpoint${res.seq ? ` #${res.seq}` : ""}${counts ? ` (${counts})` : ""}: ${message}`)
133
- if (publish) reportPublish(res)
146
+ if (publish) reportPublish(res, qrUrl(apiUrl, manifest.uuid))
134
147
  await flushComments()
135
148
  }
136
149
 
@@ -160,8 +173,8 @@ const serverPathDrift = (project: Project, manifest: Manifest): string | null =>
160
173
  return parts.length ? parts.join("; ") : null
161
174
  }
162
175
 
163
- /** Report the outcome of a --publish deploy bundled with the push (best-effort on the server). */
164
- const reportPublish = (res: { published?: boolean, publishError?: string }): void => {
165
- if (res.published) success("Published — the project is now live.")
166
- else warn(`Push landed, but publish failed: ${res.publishError ?? "unknown error"}`)
176
+ /** Report the outcome of a --publish release bundled with the push (best-effort on the server). */
177
+ const reportPublish = (res: SyncResponse, qr: string): void => {
178
+ if (res.published && res.release) reportRelease(res.release, qr)
179
+ else warn(`Push landed, but publish failed: ${res.publishError ?? "unknown error"} — \`lecodes publish\` tries again.`)
167
180
  }