lecodes-cli 0.18.2 → 0.19.2
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/dist/index.js +1866 -301
- package/package.json +14 -12
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/src/api.ts +302 -0
- package/src/browserAuth.ts +87 -0
- package/src/cmgenTool.ts +104 -0
- package/src/commands/app.ts +891 -0
- package/src/commands/appAndroid.ts +603 -0
- package/src/commands/appDesktop.ts +326 -0
- package/src/commands/appDesktopMac.ts +470 -0
- package/src/commands/appIcon.ts +187 -0
- package/src/commands/appShared.ts +448 -0
- package/src/commands/appTemplates.ts +883 -0
- package/src/commands/appTemplatesAndroid.ts +599 -0
- package/src/commands/appTemplatesGradlew.ts +9 -0
- package/src/commands/assets.ts +28 -0
- package/src/commands/clone.ts +60 -0
- package/src/commands/compile.ts +253 -0
- package/src/commands/create.ts +59 -0
- package/src/commands/design.ts +863 -0
- package/src/commands/designTemplates.ts +11 -0
- package/src/commands/desktop.ts +122 -0
- package/src/commands/dev.ts +214 -0
- package/src/commands/diff.ts +59 -0
- package/src/commands/init.ts +191 -0
- package/src/commands/install.ts +198 -0
- package/src/commands/lightmap.ts +290 -0
- package/src/commands/link.ts +147 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/navmesh.ts +225 -0
- package/src/commands/pn.ts +305 -0
- package/src/commands/projectTemplates.ts +129 -0
- package/src/commands/pull.ts +109 -0
- package/src/commands/push.ts +173 -0
- package/src/commands/render.ts +414 -0
- package/src/commands/scene.ts +148 -0
- package/src/commands/shaders.ts +157 -0
- package/src/commands/status.ts +29 -0
- package/src/commands/test.ts +370 -0
- package/src/commands/thumbs.ts +178 -0
- package/src/commands/types.ts +87 -0
- package/src/commands/update.ts +190 -0
- package/src/compile/assetIcons.ts +214 -0
- package/src/compile/collect.ts +46 -0
- package/src/compile/collectLocal.ts +37 -0
- package/src/compile/designCompile.ts +104 -0
- package/src/compile/fonts.ts +156 -0
- package/src/compile/headlessBundle.ts +129 -0
- package/src/compile/nativeStack.ts +21 -0
- package/src/compile/projectCompile.ts +109 -0
- package/src/compile/sceneCompile.ts +113 -0
- package/src/compile/screenEntry.ts +127 -0
- package/src/compile/shaders.ts +245 -0
- package/src/config.ts +42 -0
- package/src/designMeta.ts +35 -0
- package/src/desktopRenderer.ts +532 -0
- package/src/desktopScript.ts +276 -0
- package/src/dev/clientTemplates.ts +160 -0
- package/src/dev/devServer.ts +292 -0
- package/src/dev/wsServer.ts +144 -0
- package/src/distRoot.ts +20 -0
- package/src/ignore.ts +163 -0
- package/src/index.ts +491 -0
- package/src/lecodes-3d-editor.d.ts +41 -0
- package/src/lecodes-assets.d.ts +7 -0
- package/src/lecodes-design.d.ts +191 -0
- package/src/lecodes-renderer.d.ts +131 -0
- package/src/localFiles.ts +144 -0
- package/src/manifest.ts +46 -0
- package/src/matcTool.ts +137 -0
- package/src/peers.ts +40 -0
- package/src/project.ts +61 -0
- package/src/projectEnv.ts +94 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/releases.ts +125 -0
- package/src/serverDiff.ts +78 -0
- package/src/textDiff.ts +103 -0
- package/src/types.ts +0 -0
- package/src/util.ts +146 -0
- package/runtime/sdk/animate/animate.ts +0 -238
- package/runtime/sdk/animate/bezier.ts +0 -138
- package/runtime/sdk/animate/easings.ts +0 -126
- package/runtime/sdk/canvas/Canvas.ts +0 -305
- package/runtime/sdk/core/Aspect.ts +0 -512
- package/runtime/sdk/core/InspectorUI.ts +0 -212
- package/runtime/sdk/core/color.ts +0 -66
- package/runtime/sdk/core/compWrite.ts +0 -42
- package/runtime/sdk/core/events.ts +0 -38
- package/runtime/sdk/core/fields.ts +0 -120
- package/runtime/sdk/core/registry.ts +0 -23
- package/runtime/sdk/core/signals.ts +0 -277
- package/runtime/sdk/core/time.ts +0 -81
- package/runtime/sdk/g2/Camera2D.ts +0 -40
- package/runtime/sdk/g2/CharacterController2D.ts +0 -276
- package/runtime/sdk/g2/Node2D.ts +0 -267
- package/runtime/sdk/g2/OneWay2D.ts +0 -66
- package/runtime/sdk/g2/Physics2D.ts +0 -346
- package/runtime/sdk/g2/Scene2D.ts +0 -209
- package/runtime/sdk/g2/Shape2D.ts +0 -259
- package/runtime/sdk/g2/Sprite.ts +0 -89
- package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
- package/runtime/sdk/g2/SpriteSheet.ts +0 -166
- package/runtime/sdk/g2/Texture2D.ts +0 -47
- package/runtime/sdk/g2/Tilemap.ts +0 -41
- package/runtime/sdk/g2/Tileset.ts +0 -71
- package/runtime/sdk/g2/Trigger2D.ts +0 -77
- package/runtime/sdk/g2/autotile.ts +0 -433
- package/runtime/sdk/g2/cells.ts +0 -91
- package/runtime/sdk/g2/defineScene2d.ts +0 -381
- package/runtime/sdk/g2/groups2d.ts +0 -106
- package/runtime/sdk/g2/loop.ts +0 -50
- package/runtime/sdk/g2/scenarios2d.ts +0 -69
- package/runtime/sdk/g2/touch.ts +0 -83
- package/runtime/sdk/gl/Camera.ts +0 -160
- package/runtime/sdk/gl/CameraPlace.ts +0 -52
- package/runtime/sdk/gl/CharacterController.ts +0 -238
- package/runtime/sdk/gl/Gearbox.ts +0 -212
- package/runtime/sdk/gl/Geometry.ts +0 -279
- package/runtime/sdk/gl/IK.ts +0 -193
- package/runtime/sdk/gl/InstancedMesh.ts +0 -132
- package/runtime/sdk/gl/Light.ts +0 -99
- package/runtime/sdk/gl/Lightmap.ts +0 -179
- package/runtime/sdk/gl/Material.ts +0 -245
- package/runtime/sdk/gl/Mesh.ts +0 -83
- package/runtime/sdk/gl/Model.ts +0 -64
- package/runtime/sdk/gl/Node.ts +0 -350
- package/runtime/sdk/gl/Noise.ts +0 -30
- package/runtime/sdk/gl/Particles.ts +0 -676
- package/runtime/sdk/gl/Physics.ts +0 -222
- package/runtime/sdk/gl/Plane.ts +0 -53
- package/runtime/sdk/gl/Ray.ts +0 -16
- package/runtime/sdk/gl/Scene.ts +0 -479
- package/runtime/sdk/gl/Shape.ts +0 -377
- package/runtime/sdk/gl/Texture.ts +0 -46
- package/runtime/sdk/gl/Trigger.ts +0 -45
- package/runtime/sdk/gl/Vehicle.ts +0 -473
- package/runtime/sdk/gl/Wheel.ts +0 -240
- package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
- package/runtime/sdk/gl/animation/Animator.ts +0 -87
- package/runtime/sdk/gl/animation/Layer.ts +0 -29
- package/runtime/sdk/gl/animation/Loop.ts +0 -25
- package/runtime/sdk/gl/animation/Playback.ts +0 -43
- package/runtime/sdk/gl/animation/core.ts +0 -294
- package/runtime/sdk/gl/controls.ts +0 -95
- package/runtime/sdk/gl/physicsEvents.ts +0 -20
- package/runtime/sdk/gl/scenarios.ts +0 -291
- package/runtime/sdk/gl/state.ts +0 -6
- package/runtime/sdk/gl/touch.ts +0 -68
- package/runtime/sdk/inject.ts +0 -186
- package/runtime/sdk/math/Mathf.ts +0 -118
- package/runtime/sdk/math/mat4.ts +0 -278
- package/runtime/sdk/math/quat.ts +0 -232
- package/runtime/sdk/math/vec.ts +0 -255
- package/runtime/sdk/plugins/camera.ts +0 -81
- package/runtime/sdk/plugins/geolocation.ts +0 -123
- package/runtime/sdk/plugins/oauth.ts +0 -61
- package/runtime/sdk/plugins/permission.ts +0 -7
- package/runtime/sdk/plugins/push.ts +0 -132
- package/runtime/sdk/plugins/qr.ts +0 -73
- package/runtime/sdk/plugins/service.ts +0 -47
- package/runtime/sdk/runtime/app.ts +0 -101
- package/runtime/sdk/runtime/appEvents.ts +0 -54
- package/runtime/sdk/runtime/channel.ts +0 -61
- package/runtime/sdk/runtime/clipboard.ts +0 -20
- package/runtime/sdk/runtime/datetime.ts +0 -329
- package/runtime/sdk/runtime/device.ts +0 -293
- package/runtime/sdk/runtime/fetch.ts +0 -77
- package/runtime/sdk/runtime/files.ts +0 -23
- package/runtime/sdk/runtime/input.ts +0 -175
- package/runtime/sdk/runtime/media.ts +0 -111
- package/runtime/sdk/runtime/misc.ts +0 -16
- package/runtime/sdk/runtime/net.ts +0 -36
- package/runtime/sdk/runtime/rpc.ts +0 -218
- package/runtime/sdk/runtime/service.ts +0 -83
- package/runtime/sdk/runtime/share.ts +0 -9
- package/runtime/sdk/runtime/storage.ts +0 -13
- package/runtime/sdk/runtime/touch.ts +0 -76
- package/runtime/sdk/scene/defineScene.ts +0 -1227
- package/runtime/sdk/scene/editorPlugins.ts +0 -92
- package/runtime/sdk/scene/gizmos.ts +0 -148
- package/runtime/sdk/scene/grammar.ts +0 -120
- package/runtime/sdk/scene/material.ts +0 -188
- package/runtime/sdk/server/auth/appConfig.ts +0 -12
- package/runtime/sdk/server/auth/global.ts +0 -80
- package/runtime/sdk/server/auth/host.ts +0 -318
- package/runtime/sdk/server/auth/models.ts +0 -83
- package/runtime/sdk/server/auth/types.ts +0 -50
- package/runtime/sdk/server/channel.ts +0 -56
- package/runtime/sdk/server/context.ts +0 -36
- package/runtime/sdk/server/db/defineDb.ts +0 -237
- package/runtime/sdk/server/db/fields.ts +0 -132
- package/runtime/sdk/server/db/httpTransport.ts +0 -93
- package/runtime/sdk/server/db/index.ts +0 -7
- package/runtime/sdk/server/db/marci/query.ts +0 -412
- package/runtime/sdk/server/db/types.ts +0 -202
- package/runtime/sdk/server/errors.ts +0 -12
- package/runtime/sdk/server/host.ts +0 -74
- package/runtime/sdk/server/inject.ts +0 -13
- package/runtime/sdk/server/runtime.ts +0 -133
- package/runtime/sdk/server/validate.ts +0 -87
- package/runtime/sdk/ui/NativeView.ts +0 -142
- package/runtime/sdk/ui/UI.ts +0 -39
- package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
- package/runtime/sdk/ui/UIButton.ts +0 -101
- package/runtime/sdk/ui/UIContainer.ts +0 -60
- package/runtime/sdk/ui/UIImage.ts +0 -83
- package/runtime/sdk/ui/UIInput.ts +0 -185
- package/runtime/sdk/ui/UIModal.ts +0 -139
- package/runtime/sdk/ui/UINode.ts +0 -826
- package/runtime/sdk/ui/UIPager.ts +0 -362
- package/runtime/sdk/ui/UIPopover.ts +0 -100
- package/runtime/sdk/ui/UIScreen.ts +0 -123
- package/runtime/sdk/ui/UIScrollable.ts +0 -87
- package/runtime/sdk/ui/UISpacer.ts +0 -14
- package/runtime/sdk/ui/UITabs.ts +0 -236
- package/runtime/sdk/ui/UIText.ts +0 -51
- package/runtime/sdk/ui/UIVideo.ts +0 -88
- package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
- package/runtime/sdk/ui/UIWidget.ts +0 -127
- package/runtime/sdk/ui/fonts.ts +0 -13
- package/runtime/sdk/ui/presentable.ts +0 -117
- package/runtime/sdk/ui/router.ts +0 -132
- package/runtime/sdk/ui/theme.ts +0 -84
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path"
|
|
5
|
+
import { readManifest, type Manifest } from "../manifest"
|
|
6
|
+
import { loadConfig, requireApiUrl } from "../config"
|
|
7
|
+
import { compileShell, type BundleResource } from "./compile"
|
|
8
|
+
import { CliError, c, flagBool, flagStr, log, note, success, warn, type Args } from "../util"
|
|
9
|
+
import {
|
|
10
|
+
ACCENT_COLOR_JSON, APP_DELEGATE, APP_ICON_JSON, ASSETS_ROOT_JSON, IOS_README, LAUNCH_STORYBOARD,
|
|
11
|
+
SCENE_DELEGATE, SCHEME_XML, VIEW_CONTROLLER, entitlementsTemplate, infoPlistTemplate,
|
|
12
|
+
pbxprojTemplate, runtimePackageTemplate, runtimeSwiftTemplate,
|
|
13
|
+
type RuntimePlugin, type SdkVariant, type ShellIdentity,
|
|
14
|
+
} from "./appTemplates"
|
|
15
|
+
import {
|
|
16
|
+
FIRST_PARTY_PLUGINS, SDK_VARIANTS, cmpVersion, detectEngines, ensureShellIgnored,
|
|
17
|
+
fetchLatestSdkVersion, latestPluginTag, projectRoot, readJson, resolvePlugin, resolveSdkVersion,
|
|
18
|
+
resolveVariant, runTool, slugify, writeJson, VERSION_RE, type AppConfig, type LockEntry,
|
|
19
|
+
} from "./appShared"
|
|
20
|
+
import {
|
|
21
|
+
ANDROID_SDK_MIN_VERSION, ANDROID_SDK_REPO, buildAndroid, doctorAndroidChecks, initAndroid,
|
|
22
|
+
openAndroid, shellSdkVersionAndroid, syncAndroid, type SyncArtifacts,
|
|
23
|
+
} from "./appAndroid"
|
|
24
|
+
import { buildDesktop, initDesktop } from "./appDesktop"
|
|
25
|
+
import { loadIconMaster, writeIosAppIcon } from "./appIcon"
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* `lecodes app` — native app shells for a LeCodes project (docs/xcode-app-plan.md).
|
|
29
|
+
*
|
|
30
|
+
* app init ios scaffold <project>/ios/ (Xcode shell + LeCodesRuntime local package)
|
|
31
|
+
* and app.json; `ios/` is added to .lecodesignore (never pushed).
|
|
32
|
+
* The native SDK version is resolved to the latest published release and
|
|
33
|
+
* pinned in app.json ios.sdk (set it to "latest" to re-resolve every sync).
|
|
34
|
+
* app update move the pins forward: app.json ios.sdk → the newest lecodes-ios-sdk
|
|
35
|
+
* release, plugins.lock.json → the newest tag of each unpinned plugin,
|
|
36
|
+
* then re-sync. `--check` reports what's available without writing.
|
|
37
|
+
* app sync make the shell match the project: compile the bundle-first app.js into
|
|
38
|
+
* ios/App/Resources/ + embed every asset it references (server-backed
|
|
39
|
+
* projects included — offline by default; app.json resources.remote globs
|
|
40
|
+
* opt heavy assets back into streaming), pick the SDK variant
|
|
41
|
+
* (core/2d/3d/full) from the engines the bundle uses — plus the filament
|
|
42
|
+
* resources bundle when it bears 3D — vendor the plugins listed in
|
|
43
|
+
* app.json (from their git repos, pinned in plugins.lock.json), regenerate
|
|
44
|
+
* the CLI-owned files (LeCodesRuntime package, Info.plist, versions).
|
|
45
|
+
* app.json "update" configures native OTA: the shell checks that URL at
|
|
46
|
+
* launch and swaps in newer JS on the next one (LeCodesUpdater).
|
|
47
|
+
* app plugin … list / add / remove plugin entries in app.json.
|
|
48
|
+
*
|
|
49
|
+
* Ownership contract: App/*.swift are the USER's after init (sync never touches them);
|
|
50
|
+
* Info.plist, LeCodesRuntime/, App/Resources/ and the shell README are CLI-owned (sync
|
|
51
|
+
* regenerates); the pbxproj is written once — sync only restamps the version values inside it.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
const SDK_REPO = "letary/lecodes-ios-sdk"
|
|
55
|
+
|
|
56
|
+
// Oldest lecodes-ios-sdk a generated shell can use — 1.2.0 = variant matrix (core/2d/3d/full)
|
|
57
|
+
// + LeCodesUpdater (native OTA), both of which the generated files assume. The version a shell
|
|
58
|
+
// actually links is app.json `ios.sdk` (init pins the newest release, `app update` moves it);
|
|
59
|
+
// this floor is only the last-resort fallback when the network is unavailable at init.
|
|
60
|
+
const SDK_MIN_VERSION = "1.2.0"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
// First lecodes-ios-sdk release with `LeCodesEngine.bootProjectUuid` — the shell's world
|
|
64
|
+
// identity (docs/push-plan.md). On older SDKs sync simply doesn't bake the uuid (and warns
|
|
65
|
+
// when push is installed) instead of generating code the linked release can't compile.
|
|
66
|
+
const SDK_IDENTITY_VERSION = "1.6.0"
|
|
67
|
+
|
|
68
|
+
/** The SDK version a synced shell currently links (Package.swift is CLI-owned, so its
|
|
69
|
+
* `exact:` pin is the truth) — the offline fallback, so a sync without network never
|
|
70
|
+
* silently downgrades a shell. `from:` is the pre-pin shape, still read for old shells. */
|
|
71
|
+
const shellSdkVersion = (iosDir: string): string | null => {
|
|
72
|
+
const path = join(iosDir, "LeCodesRuntime", "Package.swift")
|
|
73
|
+
if (!existsSync(path)) return null
|
|
74
|
+
const m = /lecodes-ios-sdk\.git",\s*(?:exact|from):\s*"([^"]+)"/.exec(readFileSync(path, "utf8"))
|
|
75
|
+
return m && VERSION_RE.test(m[1]) ? m[1] : null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const IOS_SDK_OPTS = { repo: SDK_REPO, minVersion: SDK_MIN_VERSION, configKey: "ios.sdk" }
|
|
79
|
+
|
|
80
|
+
/* --------------------------------------- init --------------------------------------- */
|
|
81
|
+
|
|
82
|
+
const appInit = async (args: Args) => {
|
|
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
|
+
}
|
|
91
|
+
|
|
92
|
+
const root = projectRoot()
|
|
93
|
+
const iosDir = join(root, "ios")
|
|
94
|
+
if (existsSync(iosDir)) {
|
|
95
|
+
throw new CliError("ios/ already exists — run `lecodes app sync` to update it.")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const existing = readJson<AppConfig>(join(root, "app.json")) ?? {}
|
|
99
|
+
const variant = flagStr(args, "variant") ?? existing.ios?.variant ?? "auto"
|
|
100
|
+
if (variant !== "auto" && !SDK_VARIANTS.includes(variant as SdkVariant)) {
|
|
101
|
+
throw new CliError(`--variant must be auto, ${SDK_VARIANTS.join(", ")} (got "${variant}")`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The native SDK: a new shell starts on the newest published release, pinned into app.json so
|
|
105
|
+
// builds stay reproducible (`lecodes app update` moves the pin). `--sdk latest` opts into
|
|
106
|
+
// re-resolving on every sync instead.
|
|
107
|
+
const sdkFlag = flagStr(args, "sdk") ?? existing.ios?.sdk
|
|
108
|
+
const sdkVersion = await resolveSdkVersion(process.env.LECODES_IOS_SDK ?? sdkFlag ?? "latest", null, IOS_SDK_OPTS)
|
|
109
|
+
const sdk = sdkFlag === "latest" ? "latest" : sdkVersion
|
|
110
|
+
|
|
111
|
+
const name = flagStr(args, "name") ?? existing.name ?? basename(root)
|
|
112
|
+
const config: AppConfig = {
|
|
113
|
+
...existing,
|
|
114
|
+
name,
|
|
115
|
+
version: existing.version ?? "1.0.0",
|
|
116
|
+
plugins: existing.plugins ?? {},
|
|
117
|
+
ios: {
|
|
118
|
+
bundleId: flagStr(args, "bundle-id") ?? existing.ios?.bundleId ?? `com.example.${slugify(name)}`,
|
|
119
|
+
teamId: flagStr(args, "team") ?? existing.ios?.teamId ?? "",
|
|
120
|
+
variant,
|
|
121
|
+
sdk,
|
|
122
|
+
buildNumber: existing.ios?.buildNumber ?? 1,
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
writeJson(join(root, "app.json"), config)
|
|
126
|
+
log(`${c.green("written")} app.json`)
|
|
127
|
+
|
|
128
|
+
// Never push the shell to the platform.
|
|
129
|
+
ensureShellIgnored(root, "ios")
|
|
130
|
+
|
|
131
|
+
const identity: ShellIdentity = {
|
|
132
|
+
displayName: name,
|
|
133
|
+
bundleId: config.ios!.bundleId!,
|
|
134
|
+
teamId: config.ios!.teamId!,
|
|
135
|
+
version: config.version!,
|
|
136
|
+
buildNumber: config.ios!.buildNumber!,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const write = (rel: string, content: string) => {
|
|
140
|
+
const abs = join(iosDir, rel)
|
|
141
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
142
|
+
writeFileSync(abs, content)
|
|
143
|
+
log(`${c.green("created")} ios/${rel}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
write("README.md", IOS_README)
|
|
147
|
+
write("App.xcodeproj/project.pbxproj", pbxprojTemplate(identity))
|
|
148
|
+
write("App.xcodeproj/xcshareddata/xcschemes/App.xcscheme", SCHEME_XML)
|
|
149
|
+
write("App/AppDelegate.swift", APP_DELEGATE)
|
|
150
|
+
write("App/SceneDelegate.swift", SCENE_DELEGATE)
|
|
151
|
+
write("App/ViewController.swift", VIEW_CONTROLLER)
|
|
152
|
+
write("App/Info.plist", infoPlistTemplate({}))
|
|
153
|
+
write("App/App.entitlements", entitlementsTemplate({}))
|
|
154
|
+
write("App/Base.lproj/LaunchScreen.storyboard", LAUNCH_STORYBOARD)
|
|
155
|
+
write("App/Assets.xcassets/Contents.json", ASSETS_ROOT_JSON)
|
|
156
|
+
write("App/Assets.xcassets/AccentColor.colorset/Contents.json", ACCENT_COLOR_JSON)
|
|
157
|
+
write("App/Assets.xcassets/AppIcon.appiconset/Contents.json", APP_ICON_JSON)
|
|
158
|
+
// "auto" resolves at sync time (the compiled bundle says which engines it uses); until the
|
|
159
|
+
// first sync the shell gets the slimmest product as a placeholder.
|
|
160
|
+
write("LeCodesRuntime/Package.swift", runtimePackageTemplate(sdkVersion, variant === "auto" ? "core" : variant as SdkVariant, []))
|
|
161
|
+
write("LeCodesRuntime/Sources/LeCodesRuntime/LeCodesRuntime.swift", runtimeSwiftTemplate([]))
|
|
162
|
+
|
|
163
|
+
log("")
|
|
164
|
+
log(`${c.green("sdk")} lecodes-ios-sdk ${sdkVersion}${sdk === "latest" ? c.dim(" (ios.sdk: latest — re-resolved on every sync)") : c.dim(" (pinned in app.json ios.sdk — `lecodes app update` moves it)")}`)
|
|
165
|
+
success(`iOS shell for ${c.bold(name)} ready.`)
|
|
166
|
+
log(` ${c.bold("lecodes app sync")} embed the compiled bundle (+ plugins) into the shell`)
|
|
167
|
+
log(` ${c.bold("open ios/App.xcodeproj")} then run on your device from Xcode`)
|
|
168
|
+
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
|
+
}
|
|
170
|
+
|
|
171
|
+
/* --------------------------------------- sync --------------------------------------- */
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
/** The filament resources bundle that must ship beside any CreatorGL-bearing variant (3d/full).
|
|
175
|
+
* Published as a release asset next to the SDK binaries; downloaded once per SDK version into
|
|
176
|
+
* ~/.lecodes/sdk-resources/. `LECODES_SDK_RESOURCES` overrides with a local .bundle — the
|
|
177
|
+
* SDK-development loop, like LECODES_PLUGINS_DIR for plugins. */
|
|
178
|
+
const ensureSdkResourcesBundle = async (sdkVersion: string): Promise<string> => {
|
|
179
|
+
const override = process.env.LECODES_SDK_RESOURCES
|
|
180
|
+
if (override) {
|
|
181
|
+
note(`Using local resources bundle ${override} (LECODES_SDK_RESOURCES)`)
|
|
182
|
+
return resolve(override)
|
|
183
|
+
}
|
|
184
|
+
const dir = join(homedir(), ".lecodes", "sdk-resources", sdkVersion)
|
|
185
|
+
const bundle = join(dir, "LeCodesSDKResources.bundle")
|
|
186
|
+
if (existsSync(bundle)) return bundle
|
|
187
|
+
const url = `https://github.com/${SDK_REPO}/releases/download/${sdkVersion}/LeCodesSDKResources.bundle.zip`
|
|
188
|
+
note(`Downloading LeCodesSDKResources.bundle (SDK ${sdkVersion})…`)
|
|
189
|
+
const res = await fetch(url)
|
|
190
|
+
if (!res.ok) throw new CliError(`Resources bundle download failed (HTTP ${res.status}) — ${url}`)
|
|
191
|
+
mkdirSync(dir, { recursive: true })
|
|
192
|
+
const zip = join(dir, "LeCodesSDKResources.bundle.zip")
|
|
193
|
+
writeFileSync(zip, new Uint8Array(await res.arrayBuffer()))
|
|
194
|
+
execFileSync("ditto", ["-x", "-k", zip, dir]) // release zips are ditto archives (--keepParent)
|
|
195
|
+
rmSync(zip)
|
|
196
|
+
if (!existsSync(bundle)) throw new CliError(`No LeCodesSDKResources.bundle inside the release asset ${url}`)
|
|
197
|
+
return bundle
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Resolve app.json "update" to the URL the shell's native updater checks. `true` means the
|
|
201
|
+
* project's live bundle on the platform (`/code/<uuid>.js` — needs a server-backed project);
|
|
202
|
+
* strings / { url } pass through. Warns on http: ATS blocks it on device. */
|
|
203
|
+
const resolveUpdateUrl = (update: AppConfig["update"], root: string): string | undefined => {
|
|
204
|
+
if (!update) return undefined
|
|
205
|
+
let url = typeof update === "string" ? update : typeof update === "object" ? update.url : undefined
|
|
206
|
+
if (!url) {
|
|
207
|
+
let manifest: Manifest | null = null
|
|
208
|
+
try { manifest = readManifest(root) } catch { /* not a project dir */ }
|
|
209
|
+
if (!manifest) {
|
|
210
|
+
warn(`app.json "update": true needs a server-backed project — use "update": {"url": "https://…/app.js"} for custom hosting. OTA left unconfigured.`)
|
|
211
|
+
return undefined
|
|
212
|
+
}
|
|
213
|
+
url = `${requireApiUrl(loadConfig())}/code/${manifest.uuid}.js`
|
|
214
|
+
}
|
|
215
|
+
if (!url.startsWith("https://")) warn(`update URL is not https — iOS blocks it on device (ATS): ${url}`)
|
|
216
|
+
return url
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `lecodes app sync [ios|android]` — no platform syncs every shell dir that exists. A
|
|
220
|
+
* dual-shell project compiles the bundle ONCE (the iOS pass) and the Android pass reuses
|
|
221
|
+
* the byte-identical output. */
|
|
222
|
+
const appSync = async (args: Args) => {
|
|
223
|
+
const root = projectRoot()
|
|
224
|
+
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
225
|
+
const platform = args._[1]
|
|
226
|
+
if (platform !== undefined && platform !== "ios" && platform !== "android") {
|
|
227
|
+
throw new CliError(`Unknown platform "${platform}" — use ios or android.`)
|
|
228
|
+
}
|
|
229
|
+
const hasIos = !!config?.ios && existsSync(join(root, "ios"))
|
|
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
|
+
}
|
|
236
|
+
|
|
237
|
+
let iosArtifacts: SyncArtifacts | null = null
|
|
238
|
+
if (hasIos && platform !== "android") iosArtifacts = await syncIos(root, config!)
|
|
239
|
+
if (hasAndroid && platform !== "ios") await syncAndroid(root, config!, iosArtifacts)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts> => {
|
|
243
|
+
const iosDir = join(root, "ios")
|
|
244
|
+
if (!config.ios) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
245
|
+
// The variant is resolved after the compile below (the bundle header says which engines the
|
|
246
|
+
// project uses) — but reject a mistyped forced value before doing any work.
|
|
247
|
+
const requestedVariant = config.ios.variant ?? "auto"
|
|
248
|
+
if (requestedVariant !== "auto" && !SDK_VARIANTS.includes(requestedVariant as SdkVariant)) {
|
|
249
|
+
throw new CliError(`app.json ios.variant must be "auto" or one of ${SDK_VARIANTS.join(", ")} (got "${requestedVariant}").`)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// The native SDK release this shell links: app.json ios.sdk, or — for shells scaffolded before
|
|
253
|
+
// that key existed — whatever Package.swift already names, so a routine sync never moves a
|
|
254
|
+
// project's native SDK on its own (`lecodes app update` does that). "latest" re-resolves here,
|
|
255
|
+
// degrading to the linked version when offline.
|
|
256
|
+
const linkedSdk = shellSdkVersion(iosDir)
|
|
257
|
+
const sdkPin = process.env.LECODES_IOS_SDK ?? config.ios.sdk ?? linkedSdk ?? "latest"
|
|
258
|
+
const sdkVersion = await resolveSdkVersion(sdkPin, linkedSdk, IOS_SDK_OPTS)
|
|
259
|
+
|
|
260
|
+
// ---- plugins: vendor sources, collect registration + Info.plist keys, pin the lock ----
|
|
261
|
+
const lockPath = join(root, "plugins.lock.json")
|
|
262
|
+
const oldLock = readJson<{ plugins?: Record<string, LockEntry> }>(lockPath)?.plugins ?? {}
|
|
263
|
+
const pluginSpecs = Object.entries(config.plugins ?? {})
|
|
264
|
+
const runtimePlugins: RuntimePlugin[] = []
|
|
265
|
+
const infoValues: Record<string, string[]> = {}
|
|
266
|
+
const entitlementValues: Record<string, unknown[]> = {}
|
|
267
|
+
const newLock: Record<string, LockEntry> = {}
|
|
268
|
+
|
|
269
|
+
const sourcesDir = join(iosDir, "LeCodesRuntime", "Sources")
|
|
270
|
+
for (const [id, spec] of pluginSpecs) {
|
|
271
|
+
const plugin = resolvePlugin(id, spec, oldLock[id], "ios")
|
|
272
|
+
const ios = plugin.manifest.ios!
|
|
273
|
+
const module = ios.module
|
|
274
|
+
const register = ios.register
|
|
275
|
+
if (!module || !register) throw new CliError(`Plugin "${id}": manifest ios.module / ios.register missing`)
|
|
276
|
+
|
|
277
|
+
const src = join(plugin.dir, ios.sources ?? "ios")
|
|
278
|
+
if (!existsSync(src)) throw new CliError(`Plugin "${id}": sources dir ${src} missing`)
|
|
279
|
+
const dest = join(sourcesDir, module)
|
|
280
|
+
rmSync(dest, { recursive: true, force: true })
|
|
281
|
+
cpSync(src, dest, { recursive: true })
|
|
282
|
+
|
|
283
|
+
// Hard error, not a warning: below minSdk the vendored sources reference SDK API that
|
|
284
|
+
// does not exist in the linked release, so the shell cannot compile anyway.
|
|
285
|
+
if (ios.minSdk && VERSION_RE.test(ios.minSdk) && cmpVersion(sdkVersion, ios.minSdk) < 0) {
|
|
286
|
+
throw new CliError(`Plugin "${id}" needs lecodes-ios-sdk ${ios.minSdk} but the shell links ${sdkVersion} — run \`lecodes app update\` (or set app.json ios.sdk).`)
|
|
287
|
+
}
|
|
288
|
+
for (const [key, value] of Object.entries(ios.infoPlist ?? {})) {
|
|
289
|
+
(infoValues[key] ??= []).push(value)
|
|
290
|
+
}
|
|
291
|
+
for (const [key, value] of Object.entries(ios.entitlements ?? {})) {
|
|
292
|
+
(entitlementValues[key] ??= []).push(value)
|
|
293
|
+
}
|
|
294
|
+
runtimePlugins.push({ id, module, register })
|
|
295
|
+
newLock[id] = plugin.lock
|
|
296
|
+
log(`${c.green("plugin")} ${id}@${plugin.lock.version} (${plugin.lock.tag} → Sources/${module})`)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Remove targets of uninstalled plugins (everything under Sources/ except the runtime itself).
|
|
300
|
+
const keep = new Set(["LeCodesRuntime", ...runtimePlugins.map(p => p.module)])
|
|
301
|
+
for (const entry of existsSync(sourcesDir) ? readdirSync(sourcesDir) : []) {
|
|
302
|
+
if (!keep.has(entry) && statSync(join(sourcesDir, entry)).isDirectory()) {
|
|
303
|
+
rmSync(join(sourcesDir, entry), { recursive: true })
|
|
304
|
+
log(`${c.yellow("removed")} ios/LeCodesRuntime/Sources/${entry} (plugin uninstalled)`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---- bundle: compile self-contained into Resources/, then embed the assets it references ----
|
|
309
|
+
const remotePatterns = config.resources?.remote ?? []
|
|
310
|
+
if (!Array.isArray(remotePatterns) || remotePatterns.some((p) => typeof p !== "string")) {
|
|
311
|
+
throw new CliError('app.json "resources.remote" must be an array of glob strings (e.g. ["videos/**"]).')
|
|
312
|
+
}
|
|
313
|
+
const resourcesDir = join(iosDir, "App", "Resources")
|
|
314
|
+
rmSync(resourcesDir, { recursive: true, force: true })
|
|
315
|
+
mkdirSync(resourcesDir, { recursive: true })
|
|
316
|
+
const resources = await compileShell({ _: [], flags: { o: join(resourcesDir, "app.js") } }, { remotePatterns })
|
|
317
|
+
|
|
318
|
+
// ---- variant: the smallest SDK product covering what the bundle uses (or the forced one) ----
|
|
319
|
+
const bundleJs = readFileSync(join(resourcesDir, "app.js"), "utf8")
|
|
320
|
+
const { variant, detected } = resolveVariant(requestedVariant, bundleJs)
|
|
321
|
+
const WHY: Record<SdkVariant, string> = {
|
|
322
|
+
core: "UI only", "2d": "uses the 2D engine", "3d": "uses the 3D engine", full: "uses the 3D + 2D engines",
|
|
323
|
+
}
|
|
324
|
+
log(`${c.green("variant")} ${variant}${requestedVariant === "auto" ? ` (auto: ${WHY[detected]})` : " (forced by ios.variant)"}`)
|
|
325
|
+
const sdkWhy = sdkPin === "latest" ? "latest" : config.ios.sdk ? "pinned by ios.sdk" : "from the shell — set ios.sdk or run `lecodes app update`"
|
|
326
|
+
log(`${c.green("sdk")} lecodes-ios-sdk ${sdkVersion}${c.dim(` (${sdkWhy})`)}`)
|
|
327
|
+
|
|
328
|
+
// The filament resources ride along with any CreatorGL-bearing variant. Its files also back
|
|
329
|
+
// fetchLocal for SDK built-ins (lit.filamat & co), so the embed step below can skip them.
|
|
330
|
+
const sdkProvided = new Set<string>()
|
|
331
|
+
if (variant === "3d" || variant === "full") {
|
|
332
|
+
const sdkResources = await ensureSdkResourcesBundle(sdkVersion)
|
|
333
|
+
cpSync(sdkResources, join(resourcesDir, "LeCodesSDKResources.bundle"), { recursive: true })
|
|
334
|
+
for (const f of readdirSync(sdkResources)) if (f !== "Info.plist") sdkProvided.add(f)
|
|
335
|
+
log(`${c.green("resources")} LeCodesSDKResources.bundle (filament) → ios/App/Resources/`)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Embed exactly what the bundle resolves locally: the `// preload:` header names every
|
|
339
|
+
// fetchLocal resource (vendored fonts included). resources.remote entries stayed URLs, and
|
|
340
|
+
// an asset the code never references isn't shipped. Copies are flat by lookup name — the
|
|
341
|
+
// host's setFetchLocal reads Resources/ by basename, falling back to the SDK resources
|
|
342
|
+
// bundle for the sdkProvided names.
|
|
343
|
+
const preload = /^\/\/ preload: (.*)$/m.exec(bundleJs)?.[1].split(";").map((s) => s.trim()).filter(Boolean) ?? []
|
|
344
|
+
const IMPLICIT_PRELOADS = new Set(["uberarchive", "capture"]) // engine-synthesized, not project files
|
|
345
|
+
const byName = new Map<string, BundleResource[]>()
|
|
346
|
+
for (const r of resources) {
|
|
347
|
+
if (r.remote || !r.absPath) continue
|
|
348
|
+
const list = byName.get(r.name) ?? []
|
|
349
|
+
list.push(r)
|
|
350
|
+
byName.set(r.name, list)
|
|
351
|
+
}
|
|
352
|
+
let embedded = 0
|
|
353
|
+
for (const name of preload) {
|
|
354
|
+
if (IMPLICIT_PRELOADS.has(name)) continue
|
|
355
|
+
const candidates = byName.get(name) ?? []
|
|
356
|
+
if (candidates.length === 0) {
|
|
357
|
+
if (!sdkProvided.has(name)) warn(`bundle needs local asset "${name}" but no project file provides it — it won't load on device`)
|
|
358
|
+
continue
|
|
359
|
+
}
|
|
360
|
+
if (candidates.length > 1) warn(`asset name collision: ${name} (${candidates.map((r) => r.path).join(", ")}) — embedding ${candidates[0].path}`)
|
|
361
|
+
cpSync(candidates[0].absPath!, join(resourcesDir, name))
|
|
362
|
+
embedded++
|
|
363
|
+
}
|
|
364
|
+
if (embedded > 0) log(`${c.green("assets")} ${embedded} file(s) embedded → ios/App/Resources/`)
|
|
365
|
+
const streamed = resources.filter((r) => r.remote)
|
|
366
|
+
if (streamed.length > 0) log(`${c.green("remote")} ${streamed.length} resource(s) stay on the server (resources.remote): ${streamed.map((r) => r.path).join(", ")}`)
|
|
367
|
+
|
|
368
|
+
// ---- identity: a server-backed shell bakes its project uuid into the runtime, so
|
|
369
|
+
// identity-gated services (push registration) see the app's own uuid. Gated on the SDK
|
|
370
|
+
// release that carries LeCodesEngine.bootProjectUuid — on older SDKs the uuid is simply
|
|
371
|
+
// not baked (register() rejects "unavailable" exactly as before).
|
|
372
|
+
let shellUuid: string | undefined
|
|
373
|
+
try { shellUuid = readManifest(root).uuid } catch { /* local-only project — unattributed */ }
|
|
374
|
+
if (shellUuid && cmpVersion(sdkVersion, SDK_IDENTITY_VERSION) < 0) {
|
|
375
|
+
if (runtimePlugins.some(p => p.id === "push")) {
|
|
376
|
+
warn(`push needs the shell's world identity, which lecodes-ios-sdk ${SDK_IDENTITY_VERSION}+ carries — this shell links ${sdkVersion}, so Push.register() will reject "unavailable". Run \`lecodes app update\` when ${SDK_IDENTITY_VERSION} is out.`)
|
|
377
|
+
}
|
|
378
|
+
shellUuid = undefined
|
|
379
|
+
}
|
|
380
|
+
if (shellUuid) log(`${c.green("identity")} project ${shellUuid} baked into the shell (world identity)`)
|
|
381
|
+
|
|
382
|
+
// ---- regenerate the CLI-owned files ----
|
|
383
|
+
writeFileSync(join(iosDir, "README.md"), IOS_README)
|
|
384
|
+
writeFileSync(join(iosDir, "LeCodesRuntime", "Package.swift"), runtimePackageTemplate(sdkVersion, variant, runtimePlugins))
|
|
385
|
+
writeFileSync(join(sourcesDir, "LeCodesRuntime", "LeCodesRuntime.swift"), runtimeSwiftTemplate(runtimePlugins, shellUuid))
|
|
386
|
+
|
|
387
|
+
const mergedKeys: Record<string, string> = {}
|
|
388
|
+
for (const [key, values] of Object.entries(infoValues)) mergedKeys[key] = [...new Set(values)].join(" ")
|
|
389
|
+
const updateUrl = resolveUpdateUrl(config.update, root)
|
|
390
|
+
writeFileSync(join(iosDir, "App", "Info.plist"), infoPlistTemplate(mergedKeys, updateUrl))
|
|
391
|
+
if (updateUrl) log(`${c.green("update")} OTA source ${updateUrl} — checked natively at launch, new code applies next launch`)
|
|
392
|
+
else if (!config.update) {
|
|
393
|
+
// Discoverability: server-backed projects get OTA with one app.json key, but nothing
|
|
394
|
+
// else ever mentions it. One dim line, only while unconfigured.
|
|
395
|
+
let manifest: Manifest | null = null
|
|
396
|
+
try { manifest = readManifest(root) } catch { /* not a project dir */ }
|
|
397
|
+
if (manifest) note(`tip: add "update": true to app.json for over-the-air JS updates — ship with \`lecodes push --publish\`, no store release`)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (pluginSpecs.length > 0 || existsSync(lockPath)) writeJson(lockPath, { plugins: newLock })
|
|
401
|
+
|
|
402
|
+
// Version restamp — values only, never structure (the pbxproj is otherwise init-owned).
|
|
403
|
+
const pbxPath = join(iosDir, "App.xcodeproj", "project.pbxproj")
|
|
404
|
+
let pbx = readFileSync(pbxPath, "utf8")
|
|
405
|
+
.replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${config.version ?? "1.0.0"};`)
|
|
406
|
+
.replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${config.ios.buildNumber ?? 1};`)
|
|
407
|
+
|
|
408
|
+
// ---- entitlements: the capabilities the installed plugins declare (push's aps-environment).
|
|
409
|
+
// App/App.entitlements is CLI-owned and regenerated whenever the pbxproj references it (every
|
|
410
|
+
// shell scaffolded with one) or a plugin needs one. Shells from before the entitlements era
|
|
411
|
+
// get the CODE_SIGN_ENTITLEMENTS reference injected the first time a plugin brings a
|
|
412
|
+
// capability — the ONE structural pbxproj edit sync ever makes, idempotent and line-shaped.
|
|
413
|
+
const mergedEntitlements: Record<string, unknown> = {}
|
|
414
|
+
for (const [key, values] of Object.entries(entitlementValues)) {
|
|
415
|
+
if (values.every(v => Array.isArray(v))) {
|
|
416
|
+
const seen = new Set<string>()
|
|
417
|
+
mergedEntitlements[key] = (values as unknown[][]).flat()
|
|
418
|
+
.filter(v => { const s = JSON.stringify(v); if (seen.has(s)) return false; seen.add(s); return true })
|
|
419
|
+
} else {
|
|
420
|
+
if (new Set(values.map(v => JSON.stringify(v))).size > 1) {
|
|
421
|
+
warn(`entitlement ${key}: plugins declare conflicting values — keeping ${JSON.stringify(values[0])}`)
|
|
422
|
+
}
|
|
423
|
+
mergedEntitlements[key] = values[0]
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const hasEntitlements = Object.keys(mergedEntitlements).length > 0
|
|
427
|
+
if (pbx.includes("CODE_SIGN_ENTITLEMENTS") || hasEntitlements) {
|
|
428
|
+
writeFileSync(join(iosDir, "App", "App.entitlements"), entitlementsTemplate(mergedEntitlements))
|
|
429
|
+
if (hasEntitlements) log(`${c.green("caps")} App/App.entitlements: ${Object.keys(mergedEntitlements).sort().join(", ")}`)
|
|
430
|
+
if (!pbx.includes("CODE_SIGN_ENTITLEMENTS")) {
|
|
431
|
+
const patched = pbx.replace(/^(\s*)CODE_SIGN_STYLE = /gm, `$1CODE_SIGN_ENTITLEMENTS = "App/App.entitlements";\n$1CODE_SIGN_STYLE = `)
|
|
432
|
+
if (patched === pbx) {
|
|
433
|
+
warn(`couldn't wire App.entitlements into the pbxproj — add CODE_SIGN_ENTITLEMENTS = "App/App.entitlements" to the App target's build settings in Xcode.`)
|
|
434
|
+
} else {
|
|
435
|
+
pbx = patched.replace(/^(\s*)membershipExceptions = \(\n(\s*)Info\.plist,/m, `$1membershipExceptions = (\n$2App.entitlements,\n$2Info.plist,`)
|
|
436
|
+
log(`${c.green("updated")} App.xcodeproj: CODE_SIGN_ENTITLEMENTS → App/App.entitlements`)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
writeFileSync(pbxPath, pbx)
|
|
441
|
+
|
|
442
|
+
// ---- icons: rendered from app.json "icon" when set (the appiconset becomes a CLI-owned
|
|
443
|
+
// carve-out like Resources/) — absent, a hand-dropped AppIcon.png stays untouched ----
|
|
444
|
+
if (config.icon) {
|
|
445
|
+
await writeIosAppIcon(iosDir, await loadIconMaster(root, config))
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
log("")
|
|
449
|
+
success(`Shell synced: app.js + ${runtimePlugins.length} plugin(s), SDK ${sdkVersion}, variant ${variant}, v${config.version ?? "1.0.0"} (${config.ios.buildNumber ?? 1}).`)
|
|
450
|
+
note("Build & run from Xcode (open ios/App.xcodeproj) — user files in ios/App/*.swift were not touched.")
|
|
451
|
+
return { bundleJs, resources }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/* -------------------------------------- plugin -------------------------------------- */
|
|
455
|
+
|
|
456
|
+
const appPlugin = async (args: Args) => {
|
|
457
|
+
const sub = args._[1]
|
|
458
|
+
const root = projectRoot()
|
|
459
|
+
const configPath = join(root, "app.json")
|
|
460
|
+
const config = readJson<AppConfig>(configPath)
|
|
461
|
+
if (!config) throw new CliError("No app.json here — run `lecodes app init <ios|android>` first.")
|
|
462
|
+
config.plugins ??= {}
|
|
463
|
+
|
|
464
|
+
if (sub === "list" || sub === undefined) {
|
|
465
|
+
const entries = Object.entries(config.plugins)
|
|
466
|
+
if (entries.length === 0) { log("No plugins installed."); return }
|
|
467
|
+
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
|
|
468
|
+
for (const [id, spec] of entries) {
|
|
469
|
+
const pin = lock[id] ? c.dim(` ${lock[id].repo} ${lock[id].tag} (${lock[id].commit.slice(0, 7)})`) : c.dim(" (not synced)")
|
|
470
|
+
log(`${c.bold(id)} ${spec}${pin}`)
|
|
471
|
+
}
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const id = args._[2]
|
|
476
|
+
if (!id) throw new CliError(`Usage: lecodes app plugin ${sub} <id | github:owner/repo[@tag]>`)
|
|
477
|
+
|
|
478
|
+
if (sub === "add") {
|
|
479
|
+
if (id.startsWith("github:")) {
|
|
480
|
+
const bare = id.slice("github:".length).split("@")[0].split("/").pop()!
|
|
481
|
+
config.plugins[bare] = id
|
|
482
|
+
log(`${c.green("added")} ${bare} → ${id} ${c.dim("(third-party native code — review it before shipping)")}`)
|
|
483
|
+
} else if (FIRST_PARTY_PLUGINS[id]) {
|
|
484
|
+
config.plugins[id] = "*"
|
|
485
|
+
log(`${c.green("added")} ${id}`)
|
|
486
|
+
} else {
|
|
487
|
+
throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use github:owner/repo[@tag].`)
|
|
488
|
+
}
|
|
489
|
+
writeJson(configPath, config)
|
|
490
|
+
note("Run `lecodes app sync` to vendor it into the shell.")
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (sub === "remove") {
|
|
495
|
+
if (!(id in config.plugins)) throw new CliError(`Plugin "${id}" is not installed.`)
|
|
496
|
+
delete config.plugins[id]
|
|
497
|
+
writeJson(configPath, config)
|
|
498
|
+
log(`${c.yellow("removed")} ${id}`)
|
|
499
|
+
note("Run `lecodes app sync` to drop its sources from the shell.")
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
throw new CliError(`Unknown subcommand "plugin ${sub}" — use list, add or remove.`)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/* ---------------------------------- build & upload ----------------------------------- */
|
|
507
|
+
|
|
508
|
+
const exportOptionsPlist = (method: string, teamId: string, destination?: "upload"): string => `<?xml version="1.0" encoding="UTF-8"?>
|
|
509
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
510
|
+
<plist version="1.0">
|
|
511
|
+
<dict>
|
|
512
|
+
<key>method</key>
|
|
513
|
+
<string>${method}</string>
|
|
514
|
+
<key>signingStyle</key>
|
|
515
|
+
<string>automatic</string>${teamId ? `
|
|
516
|
+
<key>teamID</key>
|
|
517
|
+
<string>${teamId}</string>` : ""}${destination ? `
|
|
518
|
+
<key>destination</key>
|
|
519
|
+
<string>${destination}</string>` : ""}
|
|
520
|
+
</dict>
|
|
521
|
+
</plist>
|
|
522
|
+
`
|
|
523
|
+
|
|
524
|
+
const requireShell = (): { root: string, config: AppConfig } => {
|
|
525
|
+
const root = projectRoot()
|
|
526
|
+
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
527
|
+
if (!config?.ios || !existsSync(join(root, "ios"))) {
|
|
528
|
+
throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
529
|
+
}
|
|
530
|
+
return { root, config }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/* -------------------------------------- update --------------------------------------- */
|
|
534
|
+
|
|
535
|
+
/** One platform's SDK-pin movement inside `app update` — identical flow for both arms, only
|
|
536
|
+
* the labels/repos differ. Mutates config[platform].sdk (checkOnly writes nothing);
|
|
537
|
+
* @return true when something changed. */
|
|
538
|
+
const updateSdkPin = async (
|
|
539
|
+
root: string, config: AppConfig, args: Args, checkOnly: boolean,
|
|
540
|
+
platform: "ios" | "android",
|
|
541
|
+
): Promise<boolean> => {
|
|
542
|
+
const [label, repo, minVersion, shellVersion] = platform === "ios"
|
|
543
|
+
? ["lecodes-ios-sdk", SDK_REPO, SDK_MIN_VERSION, () => shellSdkVersion(join(root, "ios"))] as const
|
|
544
|
+
: ["lecodes-android-sdk", ANDROID_SDK_REPO, ANDROID_SDK_MIN_VERSION, () => shellSdkVersionAndroid(join(root, "android"))] as const
|
|
545
|
+
const section = config[platform]!
|
|
546
|
+
const wanted = flagStr(args, "sdk")
|
|
547
|
+
let changed = false
|
|
548
|
+
|
|
549
|
+
// No sdk key = an old shell: its CLI-owned build file is the pin sync would keep.
|
|
550
|
+
const pin = section.sdk ?? shellVersion() ?? "latest"
|
|
551
|
+
const current = pin === "latest" ? (shellVersion() ?? minVersion) : pin
|
|
552
|
+
let nextPin: string
|
|
553
|
+
if (wanted === "latest") {
|
|
554
|
+
nextPin = "latest" // float: every sync re-resolves
|
|
555
|
+
} else if (wanted) {
|
|
556
|
+
if (!VERSION_RE.test(wanted)) throw new CliError(`--sdk must be "latest" or an exact x.y.z release (got "${wanted}").`)
|
|
557
|
+
if (cmpVersion(wanted, minVersion) < 0) throw new CliError(`--sdk ${wanted} is older than the ${minVersion} these generated shells need.`)
|
|
558
|
+
nextPin = wanted
|
|
559
|
+
} else {
|
|
560
|
+
nextPin = await fetchLatestSdkVersion(repo)
|
|
561
|
+
}
|
|
562
|
+
const nextVersion = nextPin === "latest" ? await fetchLatestSdkVersion(repo) : nextPin
|
|
563
|
+
|
|
564
|
+
if (nextVersion === current && nextPin === pin) {
|
|
565
|
+
log(`${c.green("sdk")} ${label} ${current} ${c.dim("(already current)")}`)
|
|
566
|
+
} else if (nextVersion === current) {
|
|
567
|
+
log(`${c.green("sdk")} ${label} ${current} → ${platform}.sdk ${nextPin} ${c.dim("(same version, pin mode changed)")}`)
|
|
568
|
+
changed = true
|
|
569
|
+
} else {
|
|
570
|
+
const verb = cmpVersion(nextVersion, current) < 0 ? c.yellow("downgrade") : c.green("update")
|
|
571
|
+
log(`${c.green("sdk")} ${label} ${current} → ${c.bold(nextVersion)} ${c.dim(`(${verb})`)}`)
|
|
572
|
+
changed = true
|
|
573
|
+
}
|
|
574
|
+
if (!checkOnly && nextPin !== pin) {
|
|
575
|
+
section.sdk = nextPin
|
|
576
|
+
writeJson(join(root, "app.json"), config)
|
|
577
|
+
}
|
|
578
|
+
return changed
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** `lecodes app update [ios|android]` — move a shell's pins forward, the way `app sync` moves
|
|
582
|
+
* its code: app.json ios.sdk / android.sdk → the newest published SDK release,
|
|
583
|
+
* plugins.lock.json → the newest tag of every plugin whose app.json spec doesn't name one
|
|
584
|
+
* (a `github:owner/repo@tag` spec is a deliberate pin and is left alone). Then re-syncs so
|
|
585
|
+
* the shell actually carries the new versions — `--check` reports and writes nothing. */
|
|
586
|
+
const appUpdate = async (args: Args) => {
|
|
587
|
+
const root = projectRoot()
|
|
588
|
+
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
589
|
+
const platform = args._[1]
|
|
590
|
+
if (platform !== undefined && platform !== "ios" && platform !== "android") {
|
|
591
|
+
throw new CliError(`Unknown platform "${platform}" — use ios or android.`)
|
|
592
|
+
}
|
|
593
|
+
const hasIos = !!config?.ios && existsSync(join(root, "ios"))
|
|
594
|
+
const hasAndroid = !!config?.android && existsSync(join(root, "android"))
|
|
595
|
+
if (platform === "ios" && !hasIos) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
596
|
+
if (platform === "android" && !hasAndroid) throw new CliError("No Android shell here — run `lecodes app init android` first.")
|
|
597
|
+
if (!hasIos && !hasAndroid) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
598
|
+
const doIos = hasIos && platform !== "android"
|
|
599
|
+
const doAndroid = hasAndroid && platform !== "ios"
|
|
600
|
+
if (flagStr(args, "sdk") && doIos && doAndroid) {
|
|
601
|
+
throw new CliError("--sdk is ambiguous with two shells — run `lecodes app update ios --sdk …` or `lecodes app update android --sdk …`.")
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const checkOnly = flagBool(args, "check")
|
|
605
|
+
let changed = false
|
|
606
|
+
|
|
607
|
+
// ---- SDK pins ----
|
|
608
|
+
if (doIos) changed = (await updateSdkPin(root, config!, args, checkOnly, "ios")) || changed
|
|
609
|
+
if (doAndroid) changed = (await updateSdkPin(root, config!, args, checkOnly, "android")) || changed
|
|
610
|
+
|
|
611
|
+
// ---- plugins ----
|
|
612
|
+
const lockPath = join(root, "plugins.lock.json")
|
|
613
|
+
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(lockPath)?.plugins ?? {}
|
|
614
|
+
const specs = Object.entries(config!.plugins ?? {})
|
|
615
|
+
if (specs.length > 0 && !flagBool(args, "no-plugins")) {
|
|
616
|
+
const kept: Record<string, LockEntry> = {}
|
|
617
|
+
for (const [id, spec] of specs) {
|
|
618
|
+
const entry = lock[id]
|
|
619
|
+
if (spec.startsWith("github:") && spec.includes("@")) {
|
|
620
|
+
log(`${c.green("plugin")} ${id} ${c.dim(`pinned by app.json (${spec}) — not updated`)}`)
|
|
621
|
+
if (entry) kept[id] = entry
|
|
622
|
+
continue
|
|
623
|
+
}
|
|
624
|
+
const repo = spec.startsWith("github:") ? spec.slice("github:".length) : FIRST_PARTY_PLUGINS[id]
|
|
625
|
+
const tag = repo ? latestPluginTag(repo) : null
|
|
626
|
+
if (!tag) {
|
|
627
|
+
log(`${c.yellow("plugin")} ${id} ${c.dim("could not read its latest tag — left as is")}`)
|
|
628
|
+
if (entry) kept[id] = entry
|
|
629
|
+
continue
|
|
630
|
+
}
|
|
631
|
+
if (entry && entry.tag === tag) {
|
|
632
|
+
log(`${c.green("plugin")} ${id} ${entry.tag} ${c.dim("(already current)")}`)
|
|
633
|
+
kept[id] = entry
|
|
634
|
+
continue
|
|
635
|
+
}
|
|
636
|
+
log(`${c.green("plugin")} ${id} ${entry?.tag ?? "—"} → ${c.bold(tag)}`)
|
|
637
|
+
changed = true // dropped from the lock: sync re-pins it
|
|
638
|
+
}
|
|
639
|
+
if (!checkOnly && existsSync(lockPath)) writeJson(lockPath, { plugins: kept })
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
log("")
|
|
643
|
+
if (checkOnly) {
|
|
644
|
+
note(changed ? "Run `lecodes app update` to apply." : "Everything is up to date.")
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
if (!changed) { success("Already up to date."); return }
|
|
648
|
+
if (flagBool(args, "no-sync")) {
|
|
649
|
+
success("Pins updated — run `lecodes app sync` to apply them to the shell.")
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
await appSync({ _: platform ? ["sync", platform] : [], flags: {} })
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const appDoctor = async (_args: Args) => {
|
|
656
|
+
const root = projectRoot()
|
|
657
|
+
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
658
|
+
const hasIos = !!config?.ios && existsSync(join(root, "ios"))
|
|
659
|
+
const hasAndroid = !!config?.android && existsSync(join(root, "android"))
|
|
660
|
+
if (!hasIos && !hasAndroid) {
|
|
661
|
+
throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
662
|
+
}
|
|
663
|
+
let failed = 0
|
|
664
|
+
const check = (ok: boolean, label: string, hint = "", fatal = true) => {
|
|
665
|
+
log(`${ok ? c.green("✓") : (fatal ? c.red("✗") : c.yellow("!"))} ${label}${ok || !hint ? "" : c.dim(" — " + hint)}`)
|
|
666
|
+
if (!ok && fatal) failed++
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (config?.icon) {
|
|
670
|
+
check(existsSync(join(root, config.icon)), `Icon master ${config.icon}`, `app.json "icon" points at a missing file — sync will fail`)
|
|
671
|
+
}
|
|
672
|
+
if (hasIos) await doctorIosChecks(root, config!, check)
|
|
673
|
+
if (hasAndroid) {
|
|
674
|
+
await doctorAndroidChecks(root, config!, check)
|
|
675
|
+
if (!hasIos) {
|
|
676
|
+
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
|
|
677
|
+
const wanted = Object.keys(config!.plugins ?? {})
|
|
678
|
+
const inSync = wanted.every(id => lock[id]) && Object.keys(lock).every(id => wanted.includes(id))
|
|
679
|
+
check(wanted.length === 0 || inSync, "Plugins synced", "run `lecodes app sync`")
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
log("")
|
|
684
|
+
if (failed > 0) throw new CliError(`${failed} check(s) failed.`)
|
|
685
|
+
success("Ready to build.")
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const doctorIosChecks = async (
|
|
689
|
+
root: string, config: AppConfig,
|
|
690
|
+
check: (ok: boolean, label: string, hint?: string, fatal?: boolean) => void,
|
|
691
|
+
) => {
|
|
692
|
+
let xcodeVersion = ""
|
|
693
|
+
try { xcodeVersion = runTool(root, "xcodebuild", ["-version"]).split("\n")[0] } catch { /* absent */ }
|
|
694
|
+
check(xcodeVersion !== "", `Xcode CLI (${xcodeVersion || "not found"})`, "install Xcode + command line tools")
|
|
695
|
+
check(!!config.ios!.teamId, "Development team set", "lecodes app init wrote app.json — fill ios.teamId", false)
|
|
696
|
+
|
|
697
|
+
const iconDir = join(root, "ios", "App", "Assets.xcassets", "AppIcon.appiconset")
|
|
698
|
+
const hasIcon = existsSync(iconDir) && readdirSync(iconDir).some(f => f.endsWith(".png"))
|
|
699
|
+
check(hasIcon, "App icon present", "drop a 1024×1024 PNG into ios/App/Assets.xcassets/AppIcon.appiconset/ (App Store requires it)", false)
|
|
700
|
+
|
|
701
|
+
const bundle = join(root, "ios", "App", "Resources", "app.js")
|
|
702
|
+
check(existsSync(bundle), "Bundled app.js", "run `lecodes app sync`")
|
|
703
|
+
|
|
704
|
+
// Variant sanity: valid config value; the synced shell links the product the bundle needs
|
|
705
|
+
// and carries the filament resources when that product bears CreatorGL.
|
|
706
|
+
const requestedVariant = config.ios!.variant ?? "auto"
|
|
707
|
+
const validVariant = requestedVariant === "auto" || SDK_VARIANTS.includes(requestedVariant as SdkVariant)
|
|
708
|
+
check(validVariant, `ios.variant "${requestedVariant}"`, `use "auto" or one of ${SDK_VARIANTS.join(", ")}`)
|
|
709
|
+
if (existsSync(bundle) && validVariant) {
|
|
710
|
+
const { variant } = resolveVariant(requestedVariant, readFileSync(bundle, "utf8"))
|
|
711
|
+
const pkg = readFileSync(join(root, "ios", "LeCodesRuntime", "Package.swift"), "utf8")
|
|
712
|
+
const needsResources = variant === "3d" || variant === "full"
|
|
713
|
+
const hasResources = existsSync(join(root, "ios", "App", "Resources", "LeCodesSDKResources.bundle"))
|
|
714
|
+
check(pkg.includes(`"LeCodesSDK-${variant}"`) && (!needsResources || hasResources),
|
|
715
|
+
`SDK variant ${variant}${needsResources ? " (+ filament resources)" : ""}`, "run `lecodes app sync`")
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// SDK: the shell must link the version app.json asks for, and (soft, needs network) that
|
|
719
|
+
// should be the newest published release.
|
|
720
|
+
const linked = shellSdkVersion(join(root, "ios"))
|
|
721
|
+
const pin = config.ios!.sdk ?? "latest"
|
|
722
|
+
check(!!linked, `SDK ${linked ?? "version unreadable"}`, "run `lecodes app sync`")
|
|
723
|
+
if (linked && pin !== "latest") {
|
|
724
|
+
check(linked === pin, `SDK matches app.json ios.sdk (${pin})`, "run `lecodes app sync`")
|
|
725
|
+
}
|
|
726
|
+
if (linked) {
|
|
727
|
+
try {
|
|
728
|
+
const latest = await fetchLatestSdkVersion(SDK_REPO)
|
|
729
|
+
check(cmpVersion(linked, latest) >= 0, `SDK is the latest release (${latest})`, "run `lecodes app update`", false)
|
|
730
|
+
} catch { /* offline — not a preflight failure */ }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
|
|
734
|
+
const wanted = Object.keys(config.plugins ?? {})
|
|
735
|
+
const inSync = wanted.every(id => lock[id]) && Object.keys(lock).every(id => wanted.includes(id))
|
|
736
|
+
check(wanted.length === 0 || inSync, "Plugins synced", "run `lecodes app sync`")
|
|
737
|
+
|
|
738
|
+
// The pbxproj references App/App.entitlements → the file must exist or signing fails.
|
|
739
|
+
const pbxFile = join(root, "ios", "App.xcodeproj", "project.pbxproj")
|
|
740
|
+
if (existsSync(pbxFile) && readFileSync(pbxFile, "utf8").includes("CODE_SIGN_ENTITLEMENTS")) {
|
|
741
|
+
check(existsSync(join(root, "ios", "App", "App.entitlements")), "App/App.entitlements present", "run `lecodes app sync`")
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Push registration is identity-gated: the synced runtime must carry the baked uuid
|
|
745
|
+
// (server-backed project + lecodes-ios-sdk ≥ SDK_IDENTITY_VERSION + a sync since).
|
|
746
|
+
if (wanted.includes("push")) {
|
|
747
|
+
const runtimeSwift = join(root, "ios", "LeCodesRuntime", "Sources", "LeCodesRuntime", "LeCodesRuntime.swift")
|
|
748
|
+
const baked = existsSync(runtimeSwift) && readFileSync(runtimeSwift, "utf8").includes("bootProjectUuid")
|
|
749
|
+
check(baked, "Push world identity baked",
|
|
750
|
+
`without it Push.register() rejects "unavailable" — needs a pushed project (uuid), lecodes-ios-sdk ${SDK_IDENTITY_VERSION}+ and \`lecodes app sync\``, false)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (config.update) {
|
|
754
|
+
const plist = readFileSync(join(root, "ios", "App", "Info.plist"), "utf8")
|
|
755
|
+
check(plist.includes("<key>LeCodesUpdateURL</key>"), "OTA update URL in Info.plist", "run `lecodes app sync`", false)
|
|
756
|
+
// The plist key is necessary but not sufficient: the user-owned ViewController must route
|
|
757
|
+
// the launch through LeCodesUpdater. Shells generated before OTA existed read Bundle.main
|
|
758
|
+
// directly — downloads land but never run, and sync can't rewrite App/*.swift. Doctor is
|
|
759
|
+
// the only place that can catch it.
|
|
760
|
+
const appDir = join(root, "ios", "App")
|
|
761
|
+
const callsUpdater = readdirSync(appDir).some(f =>
|
|
762
|
+
f.endsWith(".swift") && readFileSync(join(appDir, f), "utf8").includes("LeCodesUpdater.activeBundle"))
|
|
763
|
+
check(callsUpdater, "App code launches via LeCodesUpdater",
|
|
764
|
+
`no App/*.swift calls LeCodesUpdater.activeBundle() — downloaded updates would never run; see "Over-the-air updates" in ios/README.md for the ViewController snippet`, false)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const asc = readJson<{ keyPath?: string }>(join(homedir(), ".lecodes", "asc.json"))
|
|
768
|
+
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)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const ARCHIVE_PATH = "ios/build/App.xcarchive"
|
|
772
|
+
|
|
773
|
+
const appBuild = async (args: Args) => {
|
|
774
|
+
const platform = args._[1]
|
|
775
|
+
if (platform === "android") return buildAndroid(args)
|
|
776
|
+
if (platform === "desktop") return buildDesktop(args)
|
|
777
|
+
if (platform !== undefined && platform !== "ios") {
|
|
778
|
+
throw new CliError(`Unknown platform "${platform}" — use ios, android or desktop.`)
|
|
779
|
+
}
|
|
780
|
+
const { root, config } = requireShell()
|
|
781
|
+
const configuration = flagStr(args, "configuration") ?? "Release"
|
|
782
|
+
const method = flagStr(args, "method") ?? "app-store-connect"
|
|
783
|
+
|
|
784
|
+
if (!flagBool(args, "no-sync")) await appSync({ _: ["sync", "ios"], flags: {} })
|
|
785
|
+
|
|
786
|
+
note(`Archiving (${configuration})… this takes a few minutes`)
|
|
787
|
+
runTool(root, "xcodebuild", [
|
|
788
|
+
"-project", "ios/App.xcodeproj", "-scheme", "App",
|
|
789
|
+
"-destination", "generic/platform=iOS", "-configuration", configuration,
|
|
790
|
+
"archive", "-archivePath", ARCHIVE_PATH, "-allowProvisioningUpdates",
|
|
791
|
+
])
|
|
792
|
+
success(`Archive: ios/build/App.xcarchive`)
|
|
793
|
+
|
|
794
|
+
if (flagBool(args, "no-export")) return
|
|
795
|
+
const optionsPath = join(root, "ios", "build", "ExportOptions.plist")
|
|
796
|
+
writeFileSync(optionsPath, exportOptionsPlist(method, config.ios!.teamId ?? ""))
|
|
797
|
+
note(`Exporting (.ipa, method ${method})…`)
|
|
798
|
+
runTool(root, "xcodebuild", [
|
|
799
|
+
"-exportArchive", "-archivePath", ARCHIVE_PATH,
|
|
800
|
+
"-exportOptionsPlist", optionsPath, "-exportPath", "ios/build/export",
|
|
801
|
+
"-allowProvisioningUpdates",
|
|
802
|
+
])
|
|
803
|
+
success("Export: ios/build/export/ — upload with `lecodes app upload ios`")
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const appUpload = async (args: Args) => {
|
|
807
|
+
if (args._[1] === "android") {
|
|
808
|
+
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).")
|
|
809
|
+
}
|
|
810
|
+
const { root, config } = requireShell()
|
|
811
|
+
if (!existsSync(join(root, ARCHIVE_PATH))) {
|
|
812
|
+
throw new CliError("No archive at ios/build/App.xcarchive — run `lecodes app build ios` first.")
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// App Store Connect API key: flags on first use, then ~/.lecodes/asc.json.
|
|
816
|
+
const ascPath = join(homedir(), ".lecodes", "asc.json")
|
|
817
|
+
const stored = readJson<{ keyPath?: string, keyId?: string, issuerId?: string }>(ascPath) ?? {}
|
|
818
|
+
const keyPath = flagStr(args, "key-path") ?? stored.keyPath
|
|
819
|
+
const keyId = flagStr(args, "key-id") ?? stored.keyId
|
|
820
|
+
const issuerId = flagStr(args, "issuer-id") ?? stored.issuerId
|
|
821
|
+
if (!keyPath || !keyId || !issuerId) {
|
|
822
|
+
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)")
|
|
823
|
+
}
|
|
824
|
+
if (!existsSync(keyPath)) throw new CliError(`API key file not found: ${keyPath}`)
|
|
825
|
+
if (flagStr(args, "key-path")) {
|
|
826
|
+
mkdirSync(dirname(ascPath), { recursive: true })
|
|
827
|
+
writeJson(ascPath, { keyPath, keyId, issuerId })
|
|
828
|
+
note("Stored the key reference in ~/.lecodes/asc.json")
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const optionsPath = join(root, "ios", "build", "UploadOptions.plist")
|
|
832
|
+
writeFileSync(optionsPath, exportOptionsPlist("app-store-connect", config.ios!.teamId ?? "", "upload"))
|
|
833
|
+
note("Uploading to App Store Connect… (processing continues on Apple's side)")
|
|
834
|
+
runTool(root, "xcodebuild", [
|
|
835
|
+
"-exportArchive", "-archivePath", ARCHIVE_PATH,
|
|
836
|
+
"-exportOptionsPlist", optionsPath, "-exportPath", "ios/build/upload",
|
|
837
|
+
"-authenticationKeyPath", resolve(keyPath),
|
|
838
|
+
"-authenticationKeyID", keyId,
|
|
839
|
+
"-authenticationKeyIssuerID", issuerId,
|
|
840
|
+
"-allowProvisioningUpdates",
|
|
841
|
+
])
|
|
842
|
+
|
|
843
|
+
// A successful upload consumes this build number — bump for the next one.
|
|
844
|
+
const configPath = join(root, "app.json")
|
|
845
|
+
const fresh = readJson<AppConfig>(configPath)!
|
|
846
|
+
fresh.ios!.buildNumber = (fresh.ios!.buildNumber ?? 1) + 1
|
|
847
|
+
writeJson(configPath, fresh)
|
|
848
|
+
success(`Uploaded build ${(fresh.ios!.buildNumber ?? 2) - 1} — check App Store Connect / TestFlight once processing finishes.`)
|
|
849
|
+
note(`ios.buildNumber bumped to ${fresh.ios!.buildNumber} for the next build.`)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/* --------------------------------------- open ---------------------------------------- */
|
|
853
|
+
|
|
854
|
+
/** `lecodes app open [ios|android]` — open the native shell in its IDE (Xcode / Android
|
|
855
|
+
* Studio). No platform: open whichever single shell exists. */
|
|
856
|
+
const appOpen = async (args: Args) => {
|
|
857
|
+
const root = projectRoot()
|
|
858
|
+
let platform = args._[1]
|
|
859
|
+
if (!platform) {
|
|
860
|
+
const hasIos = existsSync(join(root, "ios"))
|
|
861
|
+
const hasAndroid = existsSync(join(root, "android"))
|
|
862
|
+
if (hasIos && hasAndroid) throw new CliError("Both shells exist — `lecodes app open ios` or `lecodes app open android`.")
|
|
863
|
+
platform = hasAndroid ? "android" : "ios"
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (platform === "ios") {
|
|
867
|
+
const proj = join(root, "ios", "App.xcodeproj")
|
|
868
|
+
if (!existsSync(proj)) throw new CliError("No iOS shell here — run `lecodes app init ios` first.")
|
|
869
|
+
if (process.platform !== "darwin") throw new CliError("Opening Xcode requires macOS.")
|
|
870
|
+
execFileSync("open", [proj], { stdio: "ignore" })
|
|
871
|
+
success("Opened ios/App.xcodeproj in Xcode.")
|
|
872
|
+
return
|
|
873
|
+
}
|
|
874
|
+
if (platform === "android") return openAndroid()
|
|
875
|
+
throw new CliError(`Unknown platform "${platform}" — use ios or android.`)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/* --------------------------------------- entry --------------------------------------- */
|
|
879
|
+
|
|
880
|
+
export const app = async (args: Args) => {
|
|
881
|
+
const sub = args._[0]
|
|
882
|
+
if (sub === "init") return appInit(args)
|
|
883
|
+
if (sub === "sync") return appSync(args)
|
|
884
|
+
if (sub === "update") return appUpdate(args)
|
|
885
|
+
if (sub === "plugin") return appPlugin(args)
|
|
886
|
+
if (sub === "doctor") return appDoctor(args)
|
|
887
|
+
if (sub === "build") return appBuild(args)
|
|
888
|
+
if (sub === "upload") return appUpload(args)
|
|
889
|
+
if (sub === "open") return appOpen(args)
|
|
890
|
+
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`.")
|
|
891
|
+
}
|