lecodes-cli 2.2.11 → 2.2.13
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/README.md +4 -2
- package/dist/index.js +1168 -870
- package/package.json +9 -9
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/player.js +2 -2
- package/runtime/web-lite/lite.js +9 -9
- package/src/cli/args.ts +10 -3
- package/src/cli/command.ts +7 -0
- package/src/cli/run.ts +2 -1
- package/src/commands/app/android.ts +7 -10
- package/src/commands/app/desktop.ts +3 -3
- package/src/commands/app/index.ts +10 -13
- package/src/commands/app/shared.ts +43 -17
- package/src/commands/app/templates/android.ts +1 -1
- package/src/commands/index.ts +3 -1
- package/src/commands/init/templates.ts +4 -3
- package/src/commands/publish.ts +38 -0
- package/src/commands/push.ts +28 -15
- package/src/commands/releases.ts +168 -0
- package/src/hosts/desktopScript.ts +3 -3
- package/src/platform/api.ts +114 -8
- package/src/plugin/generate.ts +9 -6
- package/src/plugin/permissions.ts +42 -10
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 (
|
|
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
|
package/src/cli/command.ts
CHANGED
|
@@ -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 =
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
|
316
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
|
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.
|
|
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.
|
|
289
|
+
auto-selected; needs lecodes-android-sdk >= 2.2.11.
|
|
290
290
|
|
|
291
291
|
## App-local native code
|
|
292
292
|
|
package/src/commands/index.ts
CHANGED
|
@@ -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
|
|
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
|
+
})
|
package/src/commands/push.ts
CHANGED
|
@@ -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
|
|
17
|
-
* so the changes go live atomically. On a clean tree, --publish
|
|
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
|
|
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
|
|
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) {
|
|
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
|
|
164
|
-
const reportPublish = (res:
|
|
165
|
-
if (res.published)
|
|
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
|
}
|