lecodes-cli 0.20.2 → 2.0.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.
- package/README.md +56 -57
- package/dist/index.js +11052 -7592
- package/package.json +14 -8
- package/runtime/materials/decal-relief.mat +12 -1
- package/runtime/materials/decal.mat +17 -3
- package/runtime/materials/lightmap.mat +117 -138
- package/runtime/materials/particles-quad.mat +6 -2
- package/runtime/materials/terrain-lightmap.mat +123 -0
- package/runtime/materials/terrain.mat +36 -55
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +5 -0
- package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
- package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
- package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
- package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
- package/runtime/web/assets/map-CyBxOSV0.js +1 -0
- package/runtime/web/assets/map-XzoOFt0E.js +2 -0
- package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
- package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
- package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
- package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
- package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
- package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
- package/runtime/web/index.html +36 -0
- package/runtime/web/player.js +21 -0
- package/runtime/web-page/index.html +41 -0
- package/runtime/web-page/page.css +1 -0
- package/runtime/web-page/page.js +31 -0
- package/src/api.ts +1 -302
- package/src/cli/args.ts +37 -0
- package/src/cli/command.ts +113 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/help.ts +158 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/output.ts +102 -0
- package/src/cli/run.ts +106 -0
- package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
- package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
- package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
- package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
- package/src/commands/{app.ts → app/index.ts} +400 -290
- package/src/commands/{appShared.ts → app/shared.ts} +43 -44
- package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
- package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
- package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
- package/src/commands/assets.ts +23 -21
- package/src/commands/clone.ts +48 -51
- package/src/commands/compile.ts +76 -89
- package/src/commands/create.ts +46 -47
- package/src/commands/design/comments.ts +361 -0
- package/src/commands/design/context.ts +101 -0
- package/src/commands/design/index.ts +391 -0
- package/src/commands/design/snapshot.ts +134 -0
- package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
- package/src/commands/desktop.ts +94 -82
- package/src/commands/dev.ts +260 -182
- package/src/commands/diff.ts +39 -51
- package/src/commands/index.ts +64 -0
- package/src/commands/{init.ts → init/index.ts} +195 -191
- package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
- package/src/commands/install.ts +80 -67
- package/src/commands/lightmap.ts +543 -252
- package/src/commands/link.ts +87 -94
- package/src/commands/login.ts +25 -23
- package/src/commands/navmesh.ts +169 -139
- package/src/commands/plugin.ts +46 -0
- package/src/commands/pn.ts +201 -244
- package/src/commands/pull.ts +49 -56
- package/src/commands/push.ts +48 -54
- package/src/commands/render.ts +176 -111
- package/src/commands/scene.ts +63 -55
- package/src/commands/serve.ts +58 -0
- package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
- package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
- package/src/commands/shared.ts +79 -0
- package/src/commands/status.ts +22 -26
- package/src/commands/test.ts +94 -63
- package/src/commands/thumbs.ts +185 -175
- package/src/commands/update/index.ts +226 -0
- package/src/commands/{types.ts → update/types.ts} +31 -22
- package/src/compile/collect.ts +5 -5
- package/src/compile/collectLocal.ts +3 -3
- package/src/compile/designCompile.ts +4 -4
- package/src/compile/headlessBundle.ts +16 -12
- package/src/compile/nativeStack.ts +1 -1
- package/src/compile/projectCompile.ts +12 -4
- package/src/compile/sceneCompile.ts +14 -26
- package/src/compile/screenEntry.ts +123 -127
- package/src/compile/shaders.ts +3 -3
- package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
- package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
- package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/clientTemplates.ts +18 -14
- package/src/dev/devServer.ts +78 -41
- package/src/dev/iosDev.ts +180 -0
- package/src/dev/webRunner.ts +83 -15
- package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
- package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
- package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
- package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
- package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
- package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
- package/src/{peers.ts → hosts/peers.ts} +2 -2
- package/src/{releases.ts → hosts/releases.ts} +1 -1
- package/src/index.ts +35 -480
- package/src/platform/api.ts +302 -0
- package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
- package/src/{config.ts → platform/config.ts} +1 -1
- package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
- package/src/plugin/contract.ts +438 -0
- package/src/plugin/emitKotlin.ts +357 -0
- package/src/plugin/emitSdk.ts +259 -0
- package/src/plugin/emitSwift.ts +336 -0
- package/src/plugin/emitWeb.ts +383 -0
- package/src/plugin/generate.ts +157 -0
- package/src/plugin/lines.ts +28 -0
- package/src/plugin/lower.ts +231 -0
- package/src/plugin/manifest.ts +192 -0
- package/src/{projectEnv.ts → project/env.ts} +33 -2
- package/src/{ignore.ts → project/ignore.ts} +164 -163
- package/src/{localFiles.ts → project/localFiles.ts} +1 -1
- package/src/{manifest.ts → project/manifest.ts} +1 -1
- package/src/{project.ts → project/materialize.ts} +2 -2
- package/src/project/paths.ts +20 -0
- package/src/{textDiff.ts → project/textDiff.ts} +1 -1
- package/src/{types.ts → project/types.ts} +0 -0
- package/runtime/materials/lightmap-baked-lite.mat +0 -175
- package/runtime/materials/lightmap-baked.mat +0 -176
- package/runtime/scene-harness.json +0 -1
- package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/runtime/web/assets/basis-C64VHDVD.js +0 -1
- package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
- package/runtime/web/assets/draco-BiISTFcR.js +0 -118
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-BMt7AnC5.js +0 -2
- package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
- package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
- package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
- package/runtime/web/embed.html +0 -32
- package/runtime/web/embed.js +0 -171
- package/src/commands/design.ts +0 -846
- package/src/commands/update.ts +0 -211
- package/src/util.ts +0 -146
- /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
- /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
- /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
|
@@ -2,49 +2,57 @@ import { execFileSync } from "node:child_process"
|
|
|
2
2
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { homedir } from "node:os"
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path"
|
|
5
|
-
import { readManifest, type Manifest } from "
|
|
6
|
-
import { loadConfig, requireApiUrl } from "
|
|
7
|
-
import { compileShell, type BundleResource } from "
|
|
8
|
-
import { CliError,
|
|
5
|
+
import { readManifest, type Manifest } from "../../project/manifest"
|
|
6
|
+
import { loadConfig, requireApiUrl } from "../../platform/config"
|
|
7
|
+
import { compileShell, type BundleResource } from "../compile"
|
|
8
|
+
import { CliError, UsageError, bool, c, defineCommand, log, note, str, success, warn } from "../../cli"
|
|
9
9
|
import {
|
|
10
10
|
ACCENT_COLOR_JSON, APP_DELEGATE, APP_ICON_JSON, ASSETS_ROOT_JSON, IOS_README, LAUNCH_STORYBOARD,
|
|
11
11
|
SCENE_DELEGATE, SCHEME_XML, VIEW_CONTROLLER, entitlementsTemplate, infoPlistTemplate,
|
|
12
12
|
pbxprojTemplate, runtimePackageTemplate, runtimeSwiftTemplate,
|
|
13
13
|
type RuntimePlugin, type SdkVariant, type ShellIdentity,
|
|
14
|
-
} from "./
|
|
14
|
+
} from "./templates/ios"
|
|
15
15
|
import {
|
|
16
|
-
FIRST_PARTY_PLUGINS, SDK_VARIANTS, cmpVersion,
|
|
16
|
+
FIRST_PARTY_PLUGINS, SDK_VARIANTS, cmpVersion, ensureShellIgnored,
|
|
17
17
|
fetchLatestSdkVersion, latestPluginTag, projectRoot, readJson, resolvePlugin, resolveSdkVersion,
|
|
18
18
|
resolveVariant, runTool, slugify, writeJson, VERSION_RE, type AppConfig, type LockEntry,
|
|
19
|
-
} from "./
|
|
19
|
+
} from "./shared"
|
|
20
20
|
import {
|
|
21
|
-
ANDROID_SDK_MIN_VERSION, ANDROID_SDK_REPO,
|
|
22
|
-
openAndroid, shellSdkVersionAndroid, syncAndroid, type SyncArtifacts,
|
|
23
|
-
} from "./
|
|
24
|
-
import {
|
|
25
|
-
import { loadIconMaster, writeIosAppIcon } from "./
|
|
21
|
+
ANDROID_SDK_MIN_VERSION, ANDROID_SDK_REPO, androidBuild, doctorAndroidChecks, initAndroid,
|
|
22
|
+
keystore, openAndroid, shellSdkVersionAndroid, syncAndroid, type SyncArtifacts,
|
|
23
|
+
} from "./android"
|
|
24
|
+
import { desktopBuild, initDesktop } from "./desktop"
|
|
25
|
+
import { loadIconMaster, writeIosAppIcon } from "./icon"
|
|
26
26
|
|
|
27
27
|
/*
|
|
28
|
-
* `lecodes app` — native app shells for a LeCodes project (docs/xcode-app-plan.md).
|
|
28
|
+
* `lecodes app` — native app shells for a LeCodes project (docs/xcode-app-plan.md). This file is
|
|
29
|
+
* the iOS arm plus the command tree; android.ts and desktop.ts hold the other two arms and the
|
|
30
|
+
* nodes that exist for one platform only (`build android`, `keystore`, `build desktop`).
|
|
29
31
|
*
|
|
30
|
-
* app init ios
|
|
31
|
-
* and app.json;
|
|
32
|
-
*
|
|
33
|
-
* pinned in app.json ios.sdk
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* then re-sync. `--check` reports what's available without writing.
|
|
32
|
+
* app init <ios|android|desktop> scaffold <project>/ios/ or android/ (Xcode shell + the
|
|
33
|
+
* LeCodesRuntime local package / the Gradle shell) and app.json; the shell dir
|
|
34
|
+
* is added to .lecodesignore (never pushed). The native SDK version is
|
|
35
|
+
* resolved to the latest published release and pinned in app.json ios.sdk /
|
|
36
|
+
* android.sdk (set it to "latest" to re-resolve every sync). `desktop` needs no
|
|
37
|
+
* shell — it writes app.json's desktop block.
|
|
37
38
|
* app sync make the shell match the project: compile the bundle-first app.js into
|
|
38
|
-
* ios/App/Resources/ + embed every asset it
|
|
39
|
-
* projects included — offline by default; app.json
|
|
40
|
-
* opt heavy assets back into streaming), pick the SDK
|
|
41
|
-
* (core/2d/3d/full) from the engines the bundle uses — plus the
|
|
42
|
-
* resources bundle when it bears 3D — vendor the plugins listed in
|
|
39
|
+
* ios/App/Resources/ (resp. the android assets/) + embed every asset it
|
|
40
|
+
* references (server-backed projects included — offline by default; app.json
|
|
41
|
+
* resources.remote globs opt heavy assets back into streaming), pick the SDK
|
|
42
|
+
* variant (core/2d/3d/full) from the engines the bundle uses — plus the
|
|
43
|
+
* filament resources bundle when it bears 3D — vendor the plugins listed in
|
|
43
44
|
* app.json (from their git repos, pinned in plugins.lock.json), regenerate
|
|
44
45
|
* the CLI-owned files (LeCodesRuntime package, Info.plist, versions).
|
|
45
46
|
* app.json "update" configures native OTA: the shell checks that URL at
|
|
46
47
|
* launch and swaps in newer JS on the next one (LeCodesUpdater).
|
|
48
|
+
* app update move the pins forward: app.json ios.sdk / android.sdk → the newest SDK
|
|
49
|
+
* release, plugins.lock.json → the newest tag of each unpinned plugin, then
|
|
50
|
+
* re-sync. `--check` reports what's available without writing.
|
|
47
51
|
* app plugin … list / add / remove plugin entries in app.json.
|
|
52
|
+
* app keystore … Android release signing key: create / use <file> / info (android.ts) —
|
|
53
|
+
* the path + passwords go to the project .env, metadata to app.json.
|
|
54
|
+
* app doctor preflight for building / uploading; app build ios|android|desktop;
|
|
55
|
+
* app upload ios the archive to App Store Connect; app open [ios|android] the IDE.
|
|
48
56
|
*
|
|
49
57
|
* Ownership contract: App/*.swift are the USER's after init (sync never touches them);
|
|
50
58
|
* Info.plist, LeCodesRuntime/, App/Resources/ and the shell README are CLI-owned (sync
|
|
@@ -59,7 +67,6 @@ const SDK_REPO = "letary/lecodes-ios-sdk"
|
|
|
59
67
|
// this floor is only the last-resort fallback when the network is unavailable at init.
|
|
60
68
|
const SDK_MIN_VERSION = "1.2.0"
|
|
61
69
|
|
|
62
|
-
|
|
63
70
|
// First lecodes-ios-sdk release with `LeCodesEngine.bootProjectUuid` — the shell's world
|
|
64
71
|
// identity (docs/push-plan.md). On older SDKs sync simply doesn't bake the uuid (and warns
|
|
65
72
|
// when push is installed) instead of generating code the linked release can't compile.
|
|
@@ -77,18 +84,26 @@ const shellSdkVersion = (iosDir: string): string | null => {
|
|
|
77
84
|
|
|
78
85
|
const IOS_SDK_OPTS = { repo: SDK_REPO, minVersion: SDK_MIN_VERSION, configKey: "ios.sdk" }
|
|
79
86
|
|
|
87
|
+
/** Which shells a project has: app.json declares the platform AND the shell dir exists. */
|
|
88
|
+
const shellsOf = (root: string, config: AppConfig | null): { hasIos: boolean, hasAndroid: boolean } => ({
|
|
89
|
+
hasIos: !!config?.ios && existsSync(join(root, "ios")),
|
|
90
|
+
hasAndroid: !!config?.android && existsSync(join(root, "android")),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
/** The `[ios|android]` positional several commands take. */
|
|
94
|
+
const mobilePlatform = (arg: string | undefined): "ios" | "android" | undefined => {
|
|
95
|
+
if (arg !== undefined && arg !== "ios" && arg !== "android") throw new CliError(`Unknown platform "${arg}" — use ios or android.`)
|
|
96
|
+
return arg
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const NO_IOS_SHELL = "No iOS shell here — run `lecodes app init ios` first."
|
|
100
|
+
const NO_ANDROID_SHELL = "No Android shell here — run `lecodes app init android` first."
|
|
101
|
+
|
|
80
102
|
/* --------------------------------------- init --------------------------------------- */
|
|
81
103
|
|
|
82
|
-
|
|
83
|
-
const platform = args._[1]
|
|
84
|
-
if (platform === "android") return initAndroid(args)
|
|
85
|
-
if (platform === "desktop") return initDesktop(args)
|
|
86
|
-
if (platform !== "ios") {
|
|
87
|
-
throw new CliError(platform
|
|
88
|
-
? `Unsupported platform "${platform}" — use ios, android or desktop.`
|
|
89
|
-
: "Usage: lecodes app init <ios|android|desktop> [--name <display name>] [--bundle-id <id>] [--team <team id>] [--variant auto|core|2d|3d|full] [--app-id <application id>] [--sdk <version|latest>]")
|
|
90
|
-
}
|
|
104
|
+
interface IosInitOptions { name?: string, bundleId?: string, teamId?: string, variant?: string, sdk?: string }
|
|
91
105
|
|
|
106
|
+
const initIos = async (opts: IosInitOptions): Promise<void> => {
|
|
92
107
|
const root = projectRoot()
|
|
93
108
|
const iosDir = join(root, "ios")
|
|
94
109
|
if (existsSync(iosDir)) {
|
|
@@ -96,7 +111,7 @@ const appInit = async (args: Args) => {
|
|
|
96
111
|
}
|
|
97
112
|
|
|
98
113
|
const existing = readJson<AppConfig>(join(root, "app.json")) ?? {}
|
|
99
|
-
const variant =
|
|
114
|
+
const variant = opts.variant ?? existing.ios?.variant ?? "auto"
|
|
100
115
|
if (variant !== "auto" && !SDK_VARIANTS.includes(variant as SdkVariant)) {
|
|
101
116
|
throw new CliError(`--variant must be auto, ${SDK_VARIANTS.join(", ")} (got "${variant}")`)
|
|
102
117
|
}
|
|
@@ -104,19 +119,19 @@ const appInit = async (args: Args) => {
|
|
|
104
119
|
// The native SDK: a new shell starts on the newest published release, pinned into app.json so
|
|
105
120
|
// builds stay reproducible (`lecodes app update` moves the pin). `--sdk latest` opts into
|
|
106
121
|
// re-resolving on every sync instead.
|
|
107
|
-
const sdkFlag =
|
|
122
|
+
const sdkFlag = opts.sdk ?? existing.ios?.sdk
|
|
108
123
|
const sdkVersion = await resolveSdkVersion(process.env.LECODES_IOS_SDK ?? sdkFlag ?? "latest", null, IOS_SDK_OPTS)
|
|
109
124
|
const sdk = sdkFlag === "latest" ? "latest" : sdkVersion
|
|
110
125
|
|
|
111
|
-
const name =
|
|
126
|
+
const name = opts.name ?? existing.name ?? basename(root)
|
|
112
127
|
const config: AppConfig = {
|
|
113
128
|
...existing,
|
|
114
129
|
name,
|
|
115
130
|
version: existing.version ?? "1.0.0",
|
|
116
131
|
plugins: existing.plugins ?? {},
|
|
117
132
|
ios: {
|
|
118
|
-
bundleId:
|
|
119
|
-
teamId:
|
|
133
|
+
bundleId: opts.bundleId ?? existing.ios?.bundleId ?? `com.example.${slugify(name)}`,
|
|
134
|
+
teamId: opts.teamId ?? existing.ios?.teamId ?? "",
|
|
120
135
|
variant,
|
|
121
136
|
sdk,
|
|
122
137
|
buildNumber: existing.ios?.buildNumber ?? 1,
|
|
@@ -168,8 +183,32 @@ const appInit = async (args: Args) => {
|
|
|
168
183
|
log(` App icon: set app.json "icon" to a square ≥1024px image — sync renders every size (or drop a PNG into App/Assets.xcassets/AppIcon.appiconset/)`)
|
|
169
184
|
}
|
|
170
185
|
|
|
171
|
-
|
|
186
|
+
const init = defineCommand({
|
|
187
|
+
name: "init",
|
|
188
|
+
summary: "Generate a native shell in <project>/ios/ or <project>/android/, or declare the desktop target",
|
|
189
|
+
usage: "<ios|android|desktop>",
|
|
190
|
+
description: "The shell is offline-first (it runs the bundled app.js) and never pushed to LeCodes (the dir goes to .lecodesignore). The native SDK starts on the newest published release, pinned in app.json ios.sdk / android.sdk so builds stay reproducible — `lecodes app update` moves the pin; --sdk latest floats it instead (every sync re-resolves).\n\ndesktop: no shell needed — writes app.json's desktop block (window size; --fullscreen for kiosks). Re-running keeps existing app.json values.",
|
|
191
|
+
flags: {
|
|
192
|
+
name: str("display name (default: app.json / the folder name)", { value: "<s>" }),
|
|
193
|
+
"bundle-id": str("iOS bundle identifier (default com.example.<slug>)", { value: "<id>" }),
|
|
194
|
+
"app-id": str("Android applicationId (default com.example.<slug>)", { value: "<id>" }),
|
|
195
|
+
team: str("Apple development team id", { value: "<id>" }),
|
|
196
|
+
variant: str("SDK variant: auto (default — sync picks the smallest of core/2d/3d/full covering what the bundle uses), or one of those to pin", { value: "<v>" }),
|
|
197
|
+
sdk: str("native SDK release (default: the newest one, pinned in app.json); 'latest' floats it — every sync re-resolves", { value: "<v|latest>" }),
|
|
198
|
+
fullscreen: bool("(desktop) borderless fullscreen on the primary monitor — kiosks"),
|
|
199
|
+
},
|
|
200
|
+
examples: ["lecodes app init ios --team ABCDE12345", "lecodes app init android --app-id com.acme.game", "lecodes app init desktop --fullscreen"],
|
|
201
|
+
run: ({ args, flags }) => {
|
|
202
|
+
const platform = args[0]
|
|
203
|
+
if (platform === "android") return initAndroid({ name: flags.name, appId: flags["app-id"], variant: flags.variant, sdk: flags.sdk })
|
|
204
|
+
if (platform === "desktop") return initDesktop({ name: flags.name, fullscreen: flags.fullscreen })
|
|
205
|
+
if (platform === "ios") return initIos({ name: flags.name, bundleId: flags["bundle-id"], teamId: flags.team, variant: flags.variant, sdk: flags.sdk })
|
|
206
|
+
if (platform) throw new CliError(`Unsupported platform "${platform}" — use ios, android or desktop.`)
|
|
207
|
+
throw new UsageError("Which platform? `lecodes app init <ios|android|desktop>`")
|
|
208
|
+
},
|
|
209
|
+
})
|
|
172
210
|
|
|
211
|
+
/* --------------------------------------- sync --------------------------------------- */
|
|
173
212
|
|
|
174
213
|
/** The filament resources bundle that must ship beside any CreatorGL-bearing variant (3d/full).
|
|
175
214
|
* Published as a release asset next to the SDK binaries; downloaded once per SDK version into
|
|
@@ -218,21 +257,15 @@ const resolveUpdateUrl = (update: AppConfig["update"], root: string): string | u
|
|
|
218
257
|
|
|
219
258
|
/** `lecodes app sync [ios|android]` — no platform syncs every shell dir that exists. A
|
|
220
259
|
* dual-shell project compiles the bundle ONCE (the iOS pass) and the Android pass reuses
|
|
221
|
-
* the byte-identical output. */
|
|
222
|
-
const
|
|
260
|
+
* the byte-identical output. `app update` and `app build ios` run it too. */
|
|
261
|
+
export const runSync = async (platformArg?: string): Promise<void> => {
|
|
223
262
|
const root = projectRoot()
|
|
224
263
|
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
225
|
-
const platform =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const hasAndroid = !!config?.android && existsSync(join(root, "android"))
|
|
231
|
-
if (platform === "ios" && !hasIos) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
232
|
-
if (platform === "android" && !hasAndroid) throw new CliError("No Android shell here — run `lecodes app init android` first.")
|
|
233
|
-
if (!platform && !hasIos && !hasAndroid) {
|
|
234
|
-
throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
235
|
-
}
|
|
264
|
+
const platform = mobilePlatform(platformArg)
|
|
265
|
+
const { hasIos, hasAndroid } = shellsOf(root, config)
|
|
266
|
+
if (platform === "ios" && !hasIos) throw new CliError(NO_IOS_SHELL)
|
|
267
|
+
if (platform === "android" && !hasAndroid) throw new CliError(NO_ANDROID_SHELL)
|
|
268
|
+
if (!platform && !hasIos && !hasAndroid) throw new CliError(NO_IOS_SHELL)
|
|
236
269
|
|
|
237
270
|
let iosArtifacts: SyncArtifacts | null = null
|
|
238
271
|
if (hasIos && platform !== "android") iosArtifacts = await syncIos(root, config!)
|
|
@@ -241,7 +274,7 @@ const appSync = async (args: Args) => {
|
|
|
241
274
|
|
|
242
275
|
const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts> => {
|
|
243
276
|
const iosDir = join(root, "ios")
|
|
244
|
-
if (!config.ios) throw new CliError(
|
|
277
|
+
if (!config.ios) throw new CliError(NO_IOS_SHELL)
|
|
245
278
|
// The variant is resolved after the compile below (the bundle header says which engines the
|
|
246
279
|
// project uses) — but reject a mistyped forced value before doing any work.
|
|
247
280
|
const requestedVariant = config.ios.variant ?? "auto"
|
|
@@ -318,7 +351,7 @@ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts>
|
|
|
318
351
|
const resourcesDir = join(iosDir, "App", "Resources")
|
|
319
352
|
rmSync(resourcesDir, { recursive: true, force: true })
|
|
320
353
|
mkdirSync(resourcesDir, { recursive: true })
|
|
321
|
-
const resources = await compileShell(
|
|
354
|
+
const resources = await compileShell(join(resourcesDir, "app.js"), { remotePatterns })
|
|
322
355
|
|
|
323
356
|
// ---- variant: the smallest SDK product covering what the bundle uses (or the forced one) ----
|
|
324
357
|
const bundleJs = readFileSync(join(resourcesDir, "app.js"), "utf8")
|
|
@@ -346,7 +379,9 @@ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts>
|
|
|
346
379
|
// host's setFetchLocal reads Resources/ by basename, falling back to the SDK resources
|
|
347
380
|
// bundle for the sdkProvided names.
|
|
348
381
|
const preload = /^\/\/ preload: (.*)$/m.exec(bundleJs)?.[1].split(";").map((s) => s.trim()).filter(Boolean) ?? []
|
|
349
|
-
|
|
382
|
+
// engine-synthesized, not project files: the ubershader archive, the capture flag and the
|
|
383
|
+
// engine's own materials (`material:<name>` — they ride the SDK)
|
|
384
|
+
const IMPLICIT_PRELOADS = { has: (name: string) => name === "uberarchive" || name === "capture" || name.startsWith("material:") }
|
|
350
385
|
const byName = new Map<string, BundleResource[]>()
|
|
351
386
|
for (const r of resources) {
|
|
352
387
|
if (r.remote || !r.absPath) continue
|
|
@@ -456,99 +491,95 @@ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts>
|
|
|
456
491
|
return { bundleJs, resources }
|
|
457
492
|
}
|
|
458
493
|
|
|
494
|
+
const sync = defineCommand({
|
|
495
|
+
name: "sync",
|
|
496
|
+
summary: "Make every existing shell match the project: bundle + assets + SDK variant + plugins + CLI-owned files",
|
|
497
|
+
usage: "[ios|android]",
|
|
498
|
+
description: "Compiles the bundle and embeds the assets it references (ios/App/Resources/ resp. the android assets/; offline by default, also for server projects — app.json \"resources\": {\"remote\": [globs]} keeps heavy assets streaming), picks the SDK variant from the bundle's engine use, vendors the app.json plugins (pinned in plugins.lock.json) and regenerates the CLI-owned shell files. User files (ios/App/*.swift, android/app/) are never touched.\n\napp.json \"update\": true (or {\"url\": …}) enables native OTA JS updates at launch (iOS). app.json \"icon\" (a square ≥1024px image) renders the app icons every sync (\"iconBackground\": the adaptive / flatten backdrop). No platform = every shell that exists; a dual-shell project compiles once.",
|
|
499
|
+
examples: ["lecodes app sync", "lecodes app sync android"],
|
|
500
|
+
run: ({ args }) => runSync(args[0]),
|
|
501
|
+
})
|
|
502
|
+
|
|
459
503
|
/* -------------------------------------- plugin -------------------------------------- */
|
|
460
504
|
|
|
461
|
-
const
|
|
462
|
-
const sub = args._[1]
|
|
505
|
+
const pluginConfig = (): { root: string, configPath: string, config: AppConfig & { plugins: Record<string, string> } } => {
|
|
463
506
|
const root = projectRoot()
|
|
464
507
|
const configPath = join(root, "app.json")
|
|
465
508
|
const config = readJson<AppConfig>(configPath)
|
|
466
509
|
if (!config) throw new CliError("No app.json here — run `lecodes app init <ios|android>` first.")
|
|
467
510
|
config.plugins ??= {}
|
|
511
|
+
return { root, configPath, config: config as AppConfig & { plugins: Record<string, string> } }
|
|
512
|
+
}
|
|
468
513
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
return
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const id = args._[2]
|
|
481
|
-
if (!id) throw new CliError(`Usage: lecodes app plugin ${sub} <id | github:owner/repo[@tag]>`)
|
|
482
|
-
|
|
483
|
-
if (sub === "add") {
|
|
484
|
-
if (id.startsWith("github:")) {
|
|
485
|
-
const bare = id.slice("github:".length).split("@")[0].split("/").pop()!
|
|
486
|
-
config.plugins[bare] = id
|
|
487
|
-
log(`${c.green("added")} ${bare} → ${id} ${c.dim("(third-party native code — review it before shipping)")}`)
|
|
488
|
-
} else if (FIRST_PARTY_PLUGINS[id]) {
|
|
489
|
-
config.plugins[id] = "*"
|
|
490
|
-
log(`${c.green("added")} ${id}`)
|
|
491
|
-
} else {
|
|
492
|
-
throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use github:owner/repo[@tag].`)
|
|
493
|
-
}
|
|
494
|
-
writeJson(configPath, config)
|
|
495
|
-
note("Run `lecodes app sync` to vendor it into the shell.")
|
|
496
|
-
return
|
|
514
|
+
const pluginList = (): void => {
|
|
515
|
+
const { root, config } = pluginConfig()
|
|
516
|
+
const entries = Object.entries(config.plugins)
|
|
517
|
+
if (entries.length === 0) { log("No plugins installed."); return }
|
|
518
|
+
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
|
|
519
|
+
for (const [id, spec] of entries) {
|
|
520
|
+
const pin = lock[id] ? c.dim(` ${lock[id].repo} ${lock[id].tag} (${lock[id].commit.slice(0, 7)})`) : c.dim(" (not synced)")
|
|
521
|
+
log(`${c.bold(id)} ${spec}${pin}`)
|
|
497
522
|
}
|
|
523
|
+
}
|
|
498
524
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
525
|
+
const pluginAdd = (id: string | undefined): void => {
|
|
526
|
+
if (!id) throw new UsageError("Which plugin? `lecodes app plugin add <id | github:owner/repo[@tag]>`")
|
|
527
|
+
const { configPath, config } = pluginConfig()
|
|
528
|
+
if (id.startsWith("github:")) {
|
|
529
|
+
const bare = id.slice("github:".length).split("@")[0].split("/").pop()!
|
|
530
|
+
config.plugins[bare] = id
|
|
531
|
+
log(`${c.green("added")} ${bare} → ${id} ${c.dim("(third-party native code — review it before shipping)")}`)
|
|
532
|
+
} else if (FIRST_PARTY_PLUGINS[id]) {
|
|
533
|
+
config.plugins[id] = "*"
|
|
534
|
+
log(`${c.green("added")} ${id}`)
|
|
535
|
+
} else {
|
|
536
|
+
throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use github:owner/repo[@tag].`)
|
|
506
537
|
}
|
|
507
|
-
|
|
508
|
-
|
|
538
|
+
writeJson(configPath, config)
|
|
539
|
+
note("Run `lecodes app sync` to vendor it into the shell.")
|
|
509
540
|
}
|
|
510
541
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
<key>signingStyle</key>
|
|
520
|
-
<string>automatic</string>${teamId ? `
|
|
521
|
-
<key>teamID</key>
|
|
522
|
-
<string>${teamId}</string>` : ""}${destination ? `
|
|
523
|
-
<key>destination</key>
|
|
524
|
-
<string>${destination}</string>` : ""}
|
|
525
|
-
</dict>
|
|
526
|
-
</plist>
|
|
527
|
-
`
|
|
528
|
-
|
|
529
|
-
const requireShell = (): { root: string, config: AppConfig } => {
|
|
530
|
-
const root = projectRoot()
|
|
531
|
-
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
532
|
-
if (!config?.ios || !existsSync(join(root, "ios"))) {
|
|
533
|
-
throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
534
|
-
}
|
|
535
|
-
return { root, config }
|
|
542
|
+
const pluginRemove = (id: string | undefined): void => {
|
|
543
|
+
if (!id) throw new UsageError("Which plugin? `lecodes app plugin remove <id>`")
|
|
544
|
+
const { configPath, config } = pluginConfig()
|
|
545
|
+
if (!(id in config.plugins)) throw new CliError(`Plugin "${id}" is not installed.`)
|
|
546
|
+
delete config.plugins[id]
|
|
547
|
+
writeJson(configPath, config)
|
|
548
|
+
log(`${c.yellow("removed")} ${id}`)
|
|
549
|
+
note("Run `lecodes app sync` to drop its sources from the shell.")
|
|
536
550
|
}
|
|
537
551
|
|
|
552
|
+
const plugin = defineCommand({
|
|
553
|
+
name: "plugin",
|
|
554
|
+
summary: "Manage native plugins in app.json: list | add | remove (then run `lecodes app sync`)",
|
|
555
|
+
description: `Bare \`lecodes app plugin\` lists them with their plugins.lock.json pins. \`add\` takes a first-party id (${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}) or github:owner/repo[@tag] for a third-party plugin (native code — review it before shipping). Nothing reaches the shell until the next sync.`,
|
|
556
|
+
commands: [
|
|
557
|
+
defineCommand({ name: "list", summary: "The plugins in app.json and their lock pins", run: () => pluginList() }),
|
|
558
|
+
defineCommand({
|
|
559
|
+
name: "add",
|
|
560
|
+
summary: "Add a plugin: a known id, or github:owner/repo[@tag]",
|
|
561
|
+
usage: "<id | github:owner/repo[@tag]>",
|
|
562
|
+
examples: ["lecodes app plugin add camera", "lecodes app plugin add github:acme/lecodes-plugin-thing@v1.2.0"],
|
|
563
|
+
run: ({ args }) => pluginAdd(args[0]),
|
|
564
|
+
}),
|
|
565
|
+
defineCommand({ name: "remove", summary: "Remove a plugin entry", usage: "<id>", run: ({ args }) => pluginRemove(args[0]) }),
|
|
566
|
+
],
|
|
567
|
+
run: () => pluginList(),
|
|
568
|
+
})
|
|
569
|
+
|
|
538
570
|
/* -------------------------------------- update --------------------------------------- */
|
|
539
571
|
|
|
540
572
|
/** One platform's SDK-pin movement inside `app update` — identical flow for both arms, only
|
|
541
573
|
* the labels/repos differ. Mutates config[platform].sdk (checkOnly writes nothing);
|
|
542
574
|
* @return true when something changed. */
|
|
543
575
|
const updateSdkPin = async (
|
|
544
|
-
root: string, config: AppConfig,
|
|
576
|
+
root: string, config: AppConfig, wanted: string | undefined, checkOnly: boolean,
|
|
545
577
|
platform: "ios" | "android",
|
|
546
578
|
): Promise<boolean> => {
|
|
547
579
|
const [label, repo, minVersion, shellVersion] = platform === "ios"
|
|
548
580
|
? ["lecodes-ios-sdk", SDK_REPO, SDK_MIN_VERSION, () => shellSdkVersion(join(root, "ios"))] as const
|
|
549
581
|
: ["lecodes-android-sdk", ANDROID_SDK_REPO, ANDROID_SDK_MIN_VERSION, () => shellSdkVersionAndroid(join(root, "android"))] as const
|
|
550
582
|
const section = config[platform]!
|
|
551
|
-
const wanted = flagStr(args, "sdk")
|
|
552
583
|
let changed = false
|
|
553
584
|
|
|
554
585
|
// No sdk key = an old shell: its CLI-owned build file is the pin sync would keep.
|
|
@@ -583,41 +614,40 @@ const updateSdkPin = async (
|
|
|
583
614
|
return changed
|
|
584
615
|
}
|
|
585
616
|
|
|
617
|
+
export interface AppUpdateOptions { platform?: string, sdk?: string, check?: boolean, noPlugins?: boolean, noSync?: boolean }
|
|
618
|
+
|
|
586
619
|
/** `lecodes app update [ios|android]` — move a shell's pins forward, the way `app sync` moves
|
|
587
620
|
* its code: app.json ios.sdk / android.sdk → the newest published SDK release,
|
|
588
621
|
* plugins.lock.json → the newest tag of every plugin whose app.json spec doesn't name one
|
|
589
622
|
* (a `github:owner/repo@tag` spec is a deliberate pin and is left alone). Then re-syncs so
|
|
590
|
-
* the shell actually carries the new versions — `--check` reports and writes nothing.
|
|
591
|
-
|
|
623
|
+
* the shell actually carries the new versions — `--check` reports and writes nothing.
|
|
624
|
+
* `lecodes update` runs it as its shells step. */
|
|
625
|
+
export const runAppUpdate = async (opts: AppUpdateOptions = {}): Promise<void> => {
|
|
592
626
|
const root = projectRoot()
|
|
593
627
|
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
594
|
-
const platform =
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const hasAndroid = !!config?.android && existsSync(join(root, "android"))
|
|
600
|
-
if (platform === "ios" && !hasIos) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
601
|
-
if (platform === "android" && !hasAndroid) throw new CliError("No Android shell here — run `lecodes app init android` first.")
|
|
602
|
-
if (!hasIos && !hasAndroid) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
628
|
+
const platform = mobilePlatform(opts.platform)
|
|
629
|
+
const { hasIos, hasAndroid } = shellsOf(root, config)
|
|
630
|
+
if (platform === "ios" && !hasIos) throw new CliError(NO_IOS_SHELL)
|
|
631
|
+
if (platform === "android" && !hasAndroid) throw new CliError(NO_ANDROID_SHELL)
|
|
632
|
+
if (!hasIos && !hasAndroid) throw new CliError(NO_IOS_SHELL)
|
|
603
633
|
const doIos = hasIos && platform !== "android"
|
|
604
634
|
const doAndroid = hasAndroid && platform !== "ios"
|
|
605
|
-
if (
|
|
635
|
+
if (opts.sdk && doIos && doAndroid) {
|
|
606
636
|
throw new CliError("--sdk is ambiguous with two shells — run `lecodes app update ios --sdk …` or `lecodes app update android --sdk …`.")
|
|
607
637
|
}
|
|
608
638
|
|
|
609
|
-
const checkOnly =
|
|
639
|
+
const checkOnly = !!opts.check
|
|
610
640
|
let changed = false
|
|
611
641
|
|
|
612
642
|
// ---- SDK pins ----
|
|
613
|
-
if (doIos) changed = (await updateSdkPin(root, config!,
|
|
614
|
-
if (doAndroid) changed = (await updateSdkPin(root, config!,
|
|
643
|
+
if (doIos) changed = (await updateSdkPin(root, config!, opts.sdk, checkOnly, "ios")) || changed
|
|
644
|
+
if (doAndroid) changed = (await updateSdkPin(root, config!, opts.sdk, checkOnly, "android")) || changed
|
|
615
645
|
|
|
616
646
|
// ---- plugins ----
|
|
617
647
|
const lockPath = join(root, "plugins.lock.json")
|
|
618
648
|
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(lockPath)?.plugins ?? {}
|
|
619
649
|
const specs = Object.entries(config!.plugins ?? {})
|
|
620
|
-
if (specs.length > 0 && !
|
|
650
|
+
if (specs.length > 0 && !opts.noPlugins) {
|
|
621
651
|
const kept: Record<string, LockEntry> = {}
|
|
622
652
|
for (const [id, spec] of specs) {
|
|
623
653
|
const entry = lock[id]
|
|
@@ -650,50 +680,33 @@ const appUpdate = async (args: Args) => {
|
|
|
650
680
|
return
|
|
651
681
|
}
|
|
652
682
|
if (!changed) { success("Already up to date."); return }
|
|
653
|
-
if (
|
|
683
|
+
if (opts.noSync) {
|
|
654
684
|
success("Pins updated — run `lecodes app sync` to apply them to the shell.")
|
|
655
685
|
return
|
|
656
686
|
}
|
|
657
|
-
await
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
const appDoctor = async (_args: Args) => {
|
|
661
|
-
const root = projectRoot()
|
|
662
|
-
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
663
|
-
const hasIos = !!config?.ios && existsSync(join(root, "ios"))
|
|
664
|
-
const hasAndroid = !!config?.android && existsSync(join(root, "android"))
|
|
665
|
-
if (!hasIos && !hasAndroid) {
|
|
666
|
-
throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
667
|
-
}
|
|
668
|
-
let failed = 0
|
|
669
|
-
const check = (ok: boolean, label: string, hint = "", fatal = true) => {
|
|
670
|
-
log(`${ok ? c.green("✓") : (fatal ? c.red("✗") : c.yellow("!"))} ${label}${ok || !hint ? "" : c.dim(" — " + hint)}`)
|
|
671
|
-
if (!ok && fatal) failed++
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
if (config?.icon) {
|
|
675
|
-
check(existsSync(join(root, config.icon)), `Icon master ${config.icon}`, `app.json "icon" points at a missing file — sync will fail`)
|
|
676
|
-
}
|
|
677
|
-
if (hasIos) await doctorIosChecks(root, config!, check)
|
|
678
|
-
if (hasAndroid) {
|
|
679
|
-
await doctorAndroidChecks(root, config!, check)
|
|
680
|
-
if (!hasIos) {
|
|
681
|
-
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
|
|
682
|
-
const wanted = Object.keys(config!.plugins ?? {})
|
|
683
|
-
const inSync = wanted.every(id => lock[id]) && Object.keys(lock).every(id => wanted.includes(id))
|
|
684
|
-
check(wanted.length === 0 || inSync, "Plugins synced", "run `lecodes app sync`")
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
log("")
|
|
689
|
-
if (failed > 0) throw new CliError(`${failed} check(s) failed.`)
|
|
690
|
-
success("Ready to build.")
|
|
687
|
+
await runSync(platform)
|
|
691
688
|
}
|
|
692
689
|
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
690
|
+
const update = defineCommand({
|
|
691
|
+
name: "update",
|
|
692
|
+
summary: "Move the shell's pins forward (SDK release + plugin tags), then re-sync",
|
|
693
|
+
usage: "[ios|android]",
|
|
694
|
+
description: "app.json ios.sdk / android.sdk → the newest published SDK release, plugins.lock.json → each plugin's newest tag (a github:owner/repo@tag spec is a deliberate pin and is left alone), then `lecodes app sync` so the shell carries the new versions. `lecodes update` runs this as its shells step.",
|
|
695
|
+
flags: {
|
|
696
|
+
sdk: str("pin that release instead of the newest one; 'latest' floats the pin", { value: "<v|latest>" }),
|
|
697
|
+
check: bool("report available updates, write nothing"),
|
|
698
|
+
"no-plugins": bool("only move the SDK pin"),
|
|
699
|
+
"no-sync": bool("update the pins without re-syncing"),
|
|
700
|
+
},
|
|
701
|
+
examples: ["lecodes app update", "lecodes app update --check", "lecodes app update ios --sdk 1.6.0"],
|
|
702
|
+
run: ({ args, flags }) => runAppUpdate({ platform: args[0], sdk: flags.sdk, check: flags.check, noPlugins: flags["no-plugins"], noSync: flags["no-sync"] }),
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
/* -------------------------------------- doctor --------------------------------------- */
|
|
706
|
+
|
|
707
|
+
type Check = (ok: boolean, label: string, hint?: string, fatal?: boolean) => void
|
|
708
|
+
|
|
709
|
+
const doctorIosChecks = async (root: string, config: AppConfig, check: Check) => {
|
|
697
710
|
let xcodeVersion = ""
|
|
698
711
|
try { xcodeVersion = runTool(root, "xcodebuild", ["-version"]).split("\n")[0] } catch { /* absent */ }
|
|
699
712
|
check(xcodeVersion !== "", `Xcode CLI (${xcodeVersion || "not found"})`, "install Xcode + command line tools")
|
|
@@ -773,124 +786,221 @@ const doctorIosChecks = async (
|
|
|
773
786
|
check(!!asc?.keyPath, "App Store Connect API key configured", "pass --key-path/--key-id/--issuer-id to `lecodes app upload` once to store it", false)
|
|
774
787
|
}
|
|
775
788
|
|
|
776
|
-
const
|
|
789
|
+
const doctor = defineCommand({
|
|
790
|
+
name: "doctor",
|
|
791
|
+
summary: "Preflight checks for building / uploading the shells",
|
|
792
|
+
description: "Toolchain (Xcode / JDK + Android SDK), the synced bundle, SDK variant + version vs app.json, plugin lock, signing (team id / release keystore), icons, OTA wiring and the App Store Connect key. Fatal checks fail the command (exit 1); soft ones print a `!` and a hint.",
|
|
793
|
+
examples: ["lecodes app doctor"],
|
|
794
|
+
run: async () => {
|
|
795
|
+
const root = projectRoot()
|
|
796
|
+
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
797
|
+
const { hasIos, hasAndroid } = shellsOf(root, config)
|
|
798
|
+
if (!hasIos && !hasAndroid) throw new CliError(NO_IOS_SHELL)
|
|
799
|
+
let failed = 0
|
|
800
|
+
const check: Check = (ok, label, hint = "", fatal = true) => {
|
|
801
|
+
log(`${ok ? c.green("✓") : (fatal ? c.red("✗") : c.yellow("!"))} ${label}${ok || !hint ? "" : c.dim(" — " + hint)}`)
|
|
802
|
+
if (!ok && fatal) failed++
|
|
803
|
+
}
|
|
777
804
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
note(`Archiving (${configuration})… this takes a few minutes`)
|
|
792
|
-
runTool(root, "xcodebuild", [
|
|
793
|
-
"-project", "ios/App.xcodeproj", "-scheme", "App",
|
|
794
|
-
"-destination", "generic/platform=iOS", "-configuration", configuration,
|
|
795
|
-
"archive", "-archivePath", ARCHIVE_PATH, "-allowProvisioningUpdates",
|
|
796
|
-
])
|
|
797
|
-
success(`Archive: ios/build/App.xcarchive`)
|
|
798
|
-
|
|
799
|
-
if (flagBool(args, "no-export")) return
|
|
800
|
-
const optionsPath = join(root, "ios", "build", "ExportOptions.plist")
|
|
801
|
-
writeFileSync(optionsPath, exportOptionsPlist(method, config.ios!.teamId ?? ""))
|
|
802
|
-
note(`Exporting (.ipa, method ${method})…`)
|
|
803
|
-
runTool(root, "xcodebuild", [
|
|
804
|
-
"-exportArchive", "-archivePath", ARCHIVE_PATH,
|
|
805
|
-
"-exportOptionsPlist", optionsPath, "-exportPath", "ios/build/export",
|
|
806
|
-
"-allowProvisioningUpdates",
|
|
807
|
-
])
|
|
808
|
-
success("Export: ios/build/export/ — upload with `lecodes app upload ios`")
|
|
809
|
-
}
|
|
805
|
+
if (config?.icon) {
|
|
806
|
+
check(existsSync(join(root, config.icon)), `Icon master ${config.icon}`, `app.json "icon" points at a missing file — sync will fail`)
|
|
807
|
+
}
|
|
808
|
+
if (hasIos) await doctorIosChecks(root, config!, check)
|
|
809
|
+
if (hasAndroid) {
|
|
810
|
+
await doctorAndroidChecks(root, config!, check)
|
|
811
|
+
if (!hasIos) {
|
|
812
|
+
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
|
|
813
|
+
const wanted = Object.keys(config!.plugins ?? {})
|
|
814
|
+
const inSync = wanted.every(id => lock[id]) && Object.keys(lock).every(id => wanted.includes(id))
|
|
815
|
+
check(wanted.length === 0 || inSync, "Plugins synced", "run `lecodes app sync`")
|
|
816
|
+
}
|
|
817
|
+
}
|
|
810
818
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
if (!existsSync(join(root, ARCHIVE_PATH))) {
|
|
817
|
-
throw new CliError("No archive at ios/build/App.xcarchive — run `lecodes app build ios` first.")
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
// App Store Connect API key: flags on first use, then ~/.lecodes/asc.json.
|
|
821
|
-
const ascPath = join(homedir(), ".lecodes", "asc.json")
|
|
822
|
-
const stored = readJson<{ keyPath?: string, keyId?: string, issuerId?: string }>(ascPath) ?? {}
|
|
823
|
-
const keyPath = flagStr(args, "key-path") ?? stored.keyPath
|
|
824
|
-
const keyId = flagStr(args, "key-id") ?? stored.keyId
|
|
825
|
-
const issuerId = flagStr(args, "issuer-id") ?? stored.issuerId
|
|
826
|
-
if (!keyPath || !keyId || !issuerId) {
|
|
827
|
-
throw new CliError("App Store Connect API key needed: --key-path <AuthKey.p8> --key-id <id> --issuer-id <id>\n(create one at appstoreconnect.apple.com → Users and Access → Integrations; stored in ~/.lecodes/asc.json after first use)")
|
|
828
|
-
}
|
|
829
|
-
if (!existsSync(keyPath)) throw new CliError(`API key file not found: ${keyPath}`)
|
|
830
|
-
if (flagStr(args, "key-path")) {
|
|
831
|
-
mkdirSync(dirname(ascPath), { recursive: true })
|
|
832
|
-
writeJson(ascPath, { keyPath, keyId, issuerId })
|
|
833
|
-
note("Stored the key reference in ~/.lecodes/asc.json")
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
const optionsPath = join(root, "ios", "build", "UploadOptions.plist")
|
|
837
|
-
writeFileSync(optionsPath, exportOptionsPlist("app-store-connect", config.ios!.teamId ?? "", "upload"))
|
|
838
|
-
note("Uploading to App Store Connect… (processing continues on Apple's side)")
|
|
839
|
-
runTool(root, "xcodebuild", [
|
|
840
|
-
"-exportArchive", "-archivePath", ARCHIVE_PATH,
|
|
841
|
-
"-exportOptionsPlist", optionsPath, "-exportPath", "ios/build/upload",
|
|
842
|
-
"-authenticationKeyPath", resolve(keyPath),
|
|
843
|
-
"-authenticationKeyID", keyId,
|
|
844
|
-
"-authenticationKeyIssuerID", issuerId,
|
|
845
|
-
"-allowProvisioningUpdates",
|
|
846
|
-
])
|
|
847
|
-
|
|
848
|
-
// A successful upload consumes this build number — bump for the next one.
|
|
849
|
-
const configPath = join(root, "app.json")
|
|
850
|
-
const fresh = readJson<AppConfig>(configPath)!
|
|
851
|
-
fresh.ios!.buildNumber = (fresh.ios!.buildNumber ?? 1) + 1
|
|
852
|
-
writeJson(configPath, fresh)
|
|
853
|
-
success(`Uploaded build ${(fresh.ios!.buildNumber ?? 2) - 1} — check App Store Connect / TestFlight once processing finishes.`)
|
|
854
|
-
note(`ios.buildNumber bumped to ${fresh.ios!.buildNumber} for the next build.`)
|
|
855
|
-
}
|
|
819
|
+
log("")
|
|
820
|
+
if (failed > 0) throw new CliError(`${failed} check(s) failed.`)
|
|
821
|
+
success("Ready to build.")
|
|
822
|
+
},
|
|
823
|
+
})
|
|
856
824
|
|
|
857
|
-
/*
|
|
825
|
+
/* ---------------------------------- build & upload ----------------------------------- */
|
|
826
|
+
|
|
827
|
+
const exportOptionsPlist = (method: string, teamId: string, destination?: "upload"): string => `<?xml version="1.0" encoding="UTF-8"?>
|
|
828
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
829
|
+
<plist version="1.0">
|
|
830
|
+
<dict>
|
|
831
|
+
<key>method</key>
|
|
832
|
+
<string>${method}</string>
|
|
833
|
+
<key>signingStyle</key>
|
|
834
|
+
<string>automatic</string>${teamId ? `
|
|
835
|
+
<key>teamID</key>
|
|
836
|
+
<string>${teamId}</string>` : ""}${destination ? `
|
|
837
|
+
<key>destination</key>
|
|
838
|
+
<string>${destination}</string>` : ""}
|
|
839
|
+
</dict>
|
|
840
|
+
</plist>
|
|
841
|
+
`
|
|
858
842
|
|
|
859
|
-
|
|
860
|
-
* Studio). No platform: open whichever single shell exists. */
|
|
861
|
-
const appOpen = async (args: Args) => {
|
|
843
|
+
const requireShell = (): { root: string, config: AppConfig } => {
|
|
862
844
|
const root = projectRoot()
|
|
863
|
-
|
|
864
|
-
if (!
|
|
865
|
-
|
|
866
|
-
const hasAndroid = existsSync(join(root, "android"))
|
|
867
|
-
if (hasIos && hasAndroid) throw new CliError("Both shells exist — `lecodes app open ios` or `lecodes app open android`.")
|
|
868
|
-
platform = hasAndroid ? "android" : "ios"
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
if (platform === "ios") {
|
|
872
|
-
const proj = join(root, "ios", "App.xcodeproj")
|
|
873
|
-
if (!existsSync(proj)) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
874
|
-
if (process.platform !== "darwin") throw new CliError("Opening Xcode requires macOS.")
|
|
875
|
-
execFileSync("open", [proj], { stdio: "ignore" })
|
|
876
|
-
success("Opened ios/App.xcodeproj in Xcode.")
|
|
877
|
-
return
|
|
878
|
-
}
|
|
879
|
-
if (platform === "android") return openAndroid()
|
|
880
|
-
throw new CliError(`Unknown platform "${platform}" — use ios or android.`)
|
|
845
|
+
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
846
|
+
if (!config?.ios || !existsSync(join(root, "ios"))) throw new CliError(NO_IOS_SHELL)
|
|
847
|
+
return { root, config }
|
|
881
848
|
}
|
|
882
849
|
|
|
850
|
+
const ARCHIVE_PATH = "ios/build/App.xcarchive"
|
|
851
|
+
|
|
852
|
+
const iosBuild = defineCommand({
|
|
853
|
+
name: "ios",
|
|
854
|
+
summary: "Sync, archive and export the .ipa (automatic signing)",
|
|
855
|
+
description: "xcodebuild archive of ios/App.xcodeproj (generic iOS device, -allowProvisioningUpdates) into ios/build/App.xcarchive, then an export with the method given — the .ipa lands in ios/build/export/. `lecodes app upload ios` sends the archive to App Store Connect.",
|
|
856
|
+
flags: {
|
|
857
|
+
configuration: str("build configuration", { value: "<c>", default: "Release" }),
|
|
858
|
+
method: str("export method", { value: "<m>", default: "app-store-connect" }),
|
|
859
|
+
"no-export": bool("stop after the archive"),
|
|
860
|
+
"no-sync": bool("skip the `lecodes app sync` step"),
|
|
861
|
+
},
|
|
862
|
+
examples: ["lecodes app build ios", "lecodes app build ios --method ad-hoc"],
|
|
863
|
+
run: async ({ flags }) => {
|
|
864
|
+
const { root, config } = requireShell()
|
|
865
|
+
const configuration = flags.configuration
|
|
866
|
+
const method = flags.method
|
|
867
|
+
|
|
868
|
+
if (!flags["no-sync"]) await runSync("ios")
|
|
869
|
+
|
|
870
|
+
note(`Archiving (${configuration})… this takes a few minutes`)
|
|
871
|
+
runTool(root, "xcodebuild", [
|
|
872
|
+
"-project", "ios/App.xcodeproj", "-scheme", "App",
|
|
873
|
+
"-destination", "generic/platform=iOS", "-configuration", configuration,
|
|
874
|
+
"archive", "-archivePath", ARCHIVE_PATH, "-allowProvisioningUpdates",
|
|
875
|
+
])
|
|
876
|
+
success(`Archive: ios/build/App.xcarchive`)
|
|
877
|
+
|
|
878
|
+
if (flags["no-export"]) return
|
|
879
|
+
const optionsPath = join(root, "ios", "build", "ExportOptions.plist")
|
|
880
|
+
writeFileSync(optionsPath, exportOptionsPlist(method, config.ios!.teamId ?? ""))
|
|
881
|
+
note(`Exporting (.ipa, method ${method})…`)
|
|
882
|
+
runTool(root, "xcodebuild", [
|
|
883
|
+
"-exportArchive", "-archivePath", ARCHIVE_PATH,
|
|
884
|
+
"-exportOptionsPlist", optionsPath, "-exportPath", "ios/build/export",
|
|
885
|
+
"-allowProvisioningUpdates",
|
|
886
|
+
])
|
|
887
|
+
success("Export: ios/build/export/ — upload with `lecodes app upload ios`")
|
|
888
|
+
},
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
const build = defineCommand({
|
|
892
|
+
name: "build",
|
|
893
|
+
summary: "Build a shippable artifact: ios (.ipa) | android (.apk/.aab) | desktop (portable folder / .app)",
|
|
894
|
+
// The desktop node is `lecodes desktop build` verbatim (same flags, same run); only its name
|
|
895
|
+
// differs here, where the platform is the word after `build`.
|
|
896
|
+
commands: [iosBuild, androidBuild, { ...desktopBuild, name: "desktop" }],
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
const iosUpload = defineCommand({
|
|
900
|
+
name: "ios",
|
|
901
|
+
summary: "Upload the archive to App Store Connect (then bumps ios.buildNumber)",
|
|
902
|
+
description: "Exports ios/build/App.xcarchive with destination `upload` through xcodebuild, authenticated with an App Store Connect API key: the three flags on first use, then ~/.lecodes/asc.json remembers them. A successful upload consumes the build number, so app.json ios.buildNumber is bumped for the next build.",
|
|
903
|
+
flags: {
|
|
904
|
+
"key-path": str("App Store Connect API key (AuthKey_XXXX.p8)", { value: "<p8>" }),
|
|
905
|
+
"key-id": str("the key's id", { value: "<id>" }),
|
|
906
|
+
"issuer-id": str("the key's issuer id", { value: "<id>" }),
|
|
907
|
+
},
|
|
908
|
+
examples: ["lecodes app upload ios --key-path ~/AuthKey_ABC123.p8 --key-id ABC123 --issuer-id 1234-…", "lecodes app upload ios"],
|
|
909
|
+
run: ({ flags }) => {
|
|
910
|
+
const { root, config } = requireShell()
|
|
911
|
+
if (!existsSync(join(root, ARCHIVE_PATH))) {
|
|
912
|
+
throw new CliError("No archive at ios/build/App.xcarchive — run `lecodes app build ios` first.")
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// App Store Connect API key: flags on first use, then ~/.lecodes/asc.json.
|
|
916
|
+
const ascPath = join(homedir(), ".lecodes", "asc.json")
|
|
917
|
+
const stored = readJson<{ keyPath?: string, keyId?: string, issuerId?: string }>(ascPath) ?? {}
|
|
918
|
+
const keyPath = flags["key-path"] ?? stored.keyPath
|
|
919
|
+
const keyId = flags["key-id"] ?? stored.keyId
|
|
920
|
+
const issuerId = flags["issuer-id"] ?? stored.issuerId
|
|
921
|
+
if (!keyPath || !keyId || !issuerId) {
|
|
922
|
+
throw new CliError("App Store Connect API key needed: --key-path <AuthKey.p8> --key-id <id> --issuer-id <id>\n(create one at appstoreconnect.apple.com → Users and Access → Integrations; stored in ~/.lecodes/asc.json after first use)")
|
|
923
|
+
}
|
|
924
|
+
if (!existsSync(keyPath)) throw new CliError(`API key file not found: ${keyPath}`)
|
|
925
|
+
if (flags["key-path"]) {
|
|
926
|
+
mkdirSync(dirname(ascPath), { recursive: true })
|
|
927
|
+
writeJson(ascPath, { keyPath, keyId, issuerId })
|
|
928
|
+
note("Stored the key reference in ~/.lecodes/asc.json")
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const optionsPath = join(root, "ios", "build", "UploadOptions.plist")
|
|
932
|
+
writeFileSync(optionsPath, exportOptionsPlist("app-store-connect", config.ios!.teamId ?? "", "upload"))
|
|
933
|
+
note("Uploading to App Store Connect… (processing continues on Apple's side)")
|
|
934
|
+
runTool(root, "xcodebuild", [
|
|
935
|
+
"-exportArchive", "-archivePath", ARCHIVE_PATH,
|
|
936
|
+
"-exportOptionsPlist", optionsPath, "-exportPath", "ios/build/upload",
|
|
937
|
+
"-authenticationKeyPath", resolve(keyPath),
|
|
938
|
+
"-authenticationKeyID", keyId,
|
|
939
|
+
"-authenticationKeyIssuerID", issuerId,
|
|
940
|
+
"-allowProvisioningUpdates",
|
|
941
|
+
])
|
|
942
|
+
|
|
943
|
+
// A successful upload consumes this build number — bump for the next one.
|
|
944
|
+
const configPath = join(root, "app.json")
|
|
945
|
+
const fresh = readJson<AppConfig>(configPath)!
|
|
946
|
+
fresh.ios!.buildNumber = (fresh.ios!.buildNumber ?? 1) + 1
|
|
947
|
+
writeJson(configPath, fresh)
|
|
948
|
+
success(`Uploaded build ${(fresh.ios!.buildNumber ?? 2) - 1} — check App Store Connect / TestFlight once processing finishes.`)
|
|
949
|
+
note(`ios.buildNumber bumped to ${fresh.ios!.buildNumber} for the next build.`)
|
|
950
|
+
},
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
const upload = defineCommand({
|
|
954
|
+
name: "upload",
|
|
955
|
+
summary: "Upload a build to the store: ios (App Store Connect)",
|
|
956
|
+
commands: [
|
|
957
|
+
iosUpload,
|
|
958
|
+
// Not wired yet — kept as a node so `app upload android` says what to do instead of "unknown command".
|
|
959
|
+
defineCommand({
|
|
960
|
+
name: "android",
|
|
961
|
+
summary: "Play upload (not wired yet)",
|
|
962
|
+
hidden: true,
|
|
963
|
+
run: () => { throw new CliError("Play upload isn't wired yet — upload android/app/build/outputs/bundle/release/*.aab in the Play Console (`lecodes app build android` produces it).") },
|
|
964
|
+
}),
|
|
965
|
+
],
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
/* --------------------------------------- open ---------------------------------------- */
|
|
969
|
+
|
|
970
|
+
const open = defineCommand({
|
|
971
|
+
name: "open",
|
|
972
|
+
summary: "Open the native shell in its IDE (Xcode / Android Studio)",
|
|
973
|
+
usage: "[ios|android]",
|
|
974
|
+
description: "No platform: opens whichever single shell exists. Xcode needs macOS; Android Studio is looked up in its default install locations (or `studio` on the PATH).",
|
|
975
|
+
examples: ["lecodes app open", "lecodes app open android"],
|
|
976
|
+
run: async ({ args }) => {
|
|
977
|
+
const root = projectRoot()
|
|
978
|
+
let platform = args[0]
|
|
979
|
+
if (!platform) {
|
|
980
|
+
const hasIos = existsSync(join(root, "ios"))
|
|
981
|
+
const hasAndroid = existsSync(join(root, "android"))
|
|
982
|
+
if (hasIos && hasAndroid) throw new CliError("Both shells exist — `lecodes app open ios` or `lecodes app open android`.")
|
|
983
|
+
platform = hasAndroid ? "android" : "ios"
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (platform === "ios") {
|
|
987
|
+
const proj = join(root, "ios", "App.xcodeproj")
|
|
988
|
+
if (!existsSync(proj)) throw new CliError(NO_IOS_SHELL)
|
|
989
|
+
if (process.platform !== "darwin") throw new CliError("Opening Xcode requires macOS.")
|
|
990
|
+
execFileSync("open", [proj], { stdio: "ignore" })
|
|
991
|
+
success("Opened ios/App.xcodeproj in Xcode.")
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
if (platform === "android") return openAndroid()
|
|
995
|
+
throw new CliError(`Unknown platform "${platform}" — use ios or android.`)
|
|
996
|
+
},
|
|
997
|
+
})
|
|
998
|
+
|
|
883
999
|
/* --------------------------------------- entry --------------------------------------- */
|
|
884
1000
|
|
|
885
|
-
export
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
if (sub === "doctor") return appDoctor(args)
|
|
892
|
-
if (sub === "build") return appBuild(args)
|
|
893
|
-
if (sub === "upload") return appUpload(args)
|
|
894
|
-
if (sub === "open") return appOpen(args)
|
|
895
|
-
throw new CliError("Usage: lecodes app <init ios|android|desktop | sync | update | open | plugin list|add|remove | doctor | build ios|android|desktop | upload ios> — see `lecodes help`.")
|
|
896
|
-
}
|
|
1001
|
+
export default defineCommand({
|
|
1002
|
+
name: "app",
|
|
1003
|
+
summary: "Native apps: iOS / Android shells and the portable desktop build (init, sync, update, build, upload)",
|
|
1004
|
+
description: "A shell is a generated Xcode / Gradle project in <project>/ios/ or android/ that runs the compiled bundle offline; `sync` keeps it matching the project, `build` and `upload` ship it. The desktop target needs no shell — `build desktop` stages the host + bundle into a portable folder (a signed .app on macOS). app.json carries the identity, versions, plugins, icon and per-platform settings.",
|
|
1005
|
+
commands: [init, sync, update, plugin, open, doctor, build, keystore, upload],
|
|
1006
|
+
})
|