lecodes-cli 0.20.1 → 1.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 +5920 -5022
- package/package.json +9 -7
- package/runtime/materials/decal-relief.mat +12 -1
- package/runtime/materials/decal.mat +17 -3
- package/runtime/materials/lightmap-baked-lite.mat +6 -1
- package/runtime/materials/lightmap-baked.mat +6 -1
- package/runtime/materials/lightmap.mat +3 -0
- package/runtime/materials/particles-quad.mat +17 -9
- package/runtime/materials/particles.mat +17 -9
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
- package/runtime/web/assets/{index-BTB5KV9x.js → index-Q06c6oHx.js} +1 -1
- package/runtime/web/embed.js +2 -2
- 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 +133 -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} +451 -42
- package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
- package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
- package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
- package/src/commands/{app.ts → app/index.ts} +397 -289
- package/src/commands/{appShared.ts → app/shared.ts} +30 -39
- package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
- package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
- package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
- package/src/commands/assets.ts +23 -21
- package/src/commands/clone.ts +48 -51
- package/src/commands/compile.ts +62 -88
- 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 +90 -82
- package/src/commands/dev.ts +201 -176
- package/src/commands/diff.ts +39 -51
- package/src/commands/index.ts +60 -0
- package/src/commands/{init.ts → init/index.ts} +199 -191
- package/src/commands/install.ts +80 -67
- package/src/commands/lightmap.ts +203 -171
- package/src/commands/link.ts +87 -94
- package/src/commands/login.ts +25 -23
- package/src/commands/navmesh.ts +168 -138
- 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 +158 -99
- package/src/commands/scene.ts +58 -55
- package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
- package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
- package/src/commands/shared.ts +79 -0
- package/src/commands/status.ts +22 -26
- package/src/commands/test.ts +398 -371
- 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 +3 -3
- package/src/compile/headlessBundle.ts +133 -129
- package/src/compile/projectCompile.ts +2 -2
- package/src/compile/sceneCompile.ts +10 -8
- package/src/compile/screenEntry.ts +123 -127
- package/src/compile/shaders.ts +3 -3
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/devServer.ts +3 -3
- package/src/dev/webRunner.ts +1 -1
- package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
- package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
- package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
- package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
- package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
- package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
- package/src/{releases.ts → hosts/releases.ts} +1 -1
- package/src/index.ts +32 -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/{projectEnv.ts → project/env.ts} +33 -2
- 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/web/assets/createViewerLite-C_bkKXLg.js +0 -852
- package/src/commands/design.ts +0 -846
- package/src/commands/update.ts +0 -211
- package/src/util.ts +0 -146
- /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
- /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
- /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
- /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
- /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
- /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
- /package/src/{peers.ts → hosts/peers.ts} +0 -0
- /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
- /package/src/{ignore.ts → project/ignore.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")
|
|
@@ -456,99 +489,95 @@ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts>
|
|
|
456
489
|
return { bundleJs, resources }
|
|
457
490
|
}
|
|
458
491
|
|
|
492
|
+
const sync = defineCommand({
|
|
493
|
+
name: "sync",
|
|
494
|
+
summary: "Make every existing shell match the project: bundle + assets + SDK variant + plugins + CLI-owned files",
|
|
495
|
+
usage: "[ios|android]",
|
|
496
|
+
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.",
|
|
497
|
+
examples: ["lecodes app sync", "lecodes app sync android"],
|
|
498
|
+
run: ({ args }) => runSync(args[0]),
|
|
499
|
+
})
|
|
500
|
+
|
|
459
501
|
/* -------------------------------------- plugin -------------------------------------- */
|
|
460
502
|
|
|
461
|
-
const
|
|
462
|
-
const sub = args._[1]
|
|
503
|
+
const pluginConfig = (): { root: string, configPath: string, config: AppConfig & { plugins: Record<string, string> } } => {
|
|
463
504
|
const root = projectRoot()
|
|
464
505
|
const configPath = join(root, "app.json")
|
|
465
506
|
const config = readJson<AppConfig>(configPath)
|
|
466
507
|
if (!config) throw new CliError("No app.json here — run `lecodes app init <ios|android>` first.")
|
|
467
508
|
config.plugins ??= {}
|
|
509
|
+
return { root, configPath, config: config as AppConfig & { plugins: Record<string, string> } }
|
|
510
|
+
}
|
|
468
511
|
|
|
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
|
|
512
|
+
const pluginList = (): void => {
|
|
513
|
+
const { root, config } = pluginConfig()
|
|
514
|
+
const entries = Object.entries(config.plugins)
|
|
515
|
+
if (entries.length === 0) { log("No plugins installed."); return }
|
|
516
|
+
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
|
|
517
|
+
for (const [id, spec] of entries) {
|
|
518
|
+
const pin = lock[id] ? c.dim(` ${lock[id].repo} ${lock[id].tag} (${lock[id].commit.slice(0, 7)})`) : c.dim(" (not synced)")
|
|
519
|
+
log(`${c.bold(id)} ${spec}${pin}`)
|
|
497
520
|
}
|
|
521
|
+
}
|
|
498
522
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
523
|
+
const pluginAdd = (id: string | undefined): void => {
|
|
524
|
+
if (!id) throw new UsageError("Which plugin? `lecodes app plugin add <id | github:owner/repo[@tag]>`")
|
|
525
|
+
const { configPath, config } = pluginConfig()
|
|
526
|
+
if (id.startsWith("github:")) {
|
|
527
|
+
const bare = id.slice("github:".length).split("@")[0].split("/").pop()!
|
|
528
|
+
config.plugins[bare] = id
|
|
529
|
+
log(`${c.green("added")} ${bare} → ${id} ${c.dim("(third-party native code — review it before shipping)")}`)
|
|
530
|
+
} else if (FIRST_PARTY_PLUGINS[id]) {
|
|
531
|
+
config.plugins[id] = "*"
|
|
532
|
+
log(`${c.green("added")} ${id}`)
|
|
533
|
+
} else {
|
|
534
|
+
throw new CliError(`Unknown plugin "${id}" — known: ${Object.keys(FIRST_PARTY_PLUGINS).join(", ")}, or use github:owner/repo[@tag].`)
|
|
506
535
|
}
|
|
507
|
-
|
|
508
|
-
|
|
536
|
+
writeJson(configPath, config)
|
|
537
|
+
note("Run `lecodes app sync` to vendor it into the shell.")
|
|
509
538
|
}
|
|
510
539
|
|
|
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 }
|
|
540
|
+
const pluginRemove = (id: string | undefined): void => {
|
|
541
|
+
if (!id) throw new UsageError("Which plugin? `lecodes app plugin remove <id>`")
|
|
542
|
+
const { configPath, config } = pluginConfig()
|
|
543
|
+
if (!(id in config.plugins)) throw new CliError(`Plugin "${id}" is not installed.`)
|
|
544
|
+
delete config.plugins[id]
|
|
545
|
+
writeJson(configPath, config)
|
|
546
|
+
log(`${c.yellow("removed")} ${id}`)
|
|
547
|
+
note("Run `lecodes app sync` to drop its sources from the shell.")
|
|
536
548
|
}
|
|
537
549
|
|
|
550
|
+
const plugin = defineCommand({
|
|
551
|
+
name: "plugin",
|
|
552
|
+
summary: "Manage native plugins in app.json: list | add | remove (then run `lecodes app sync`)",
|
|
553
|
+
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.`,
|
|
554
|
+
commands: [
|
|
555
|
+
defineCommand({ name: "list", summary: "The plugins in app.json and their lock pins", run: () => pluginList() }),
|
|
556
|
+
defineCommand({
|
|
557
|
+
name: "add",
|
|
558
|
+
summary: "Add a plugin: a known id, or github:owner/repo[@tag]",
|
|
559
|
+
usage: "<id | github:owner/repo[@tag]>",
|
|
560
|
+
examples: ["lecodes app plugin add camera", "lecodes app plugin add github:acme/lecodes-plugin-thing@v1.2.0"],
|
|
561
|
+
run: ({ args }) => pluginAdd(args[0]),
|
|
562
|
+
}),
|
|
563
|
+
defineCommand({ name: "remove", summary: "Remove a plugin entry", usage: "<id>", run: ({ args }) => pluginRemove(args[0]) }),
|
|
564
|
+
],
|
|
565
|
+
run: () => pluginList(),
|
|
566
|
+
})
|
|
567
|
+
|
|
538
568
|
/* -------------------------------------- update --------------------------------------- */
|
|
539
569
|
|
|
540
570
|
/** One platform's SDK-pin movement inside `app update` — identical flow for both arms, only
|
|
541
571
|
* the labels/repos differ. Mutates config[platform].sdk (checkOnly writes nothing);
|
|
542
572
|
* @return true when something changed. */
|
|
543
573
|
const updateSdkPin = async (
|
|
544
|
-
root: string, config: AppConfig,
|
|
574
|
+
root: string, config: AppConfig, wanted: string | undefined, checkOnly: boolean,
|
|
545
575
|
platform: "ios" | "android",
|
|
546
576
|
): Promise<boolean> => {
|
|
547
577
|
const [label, repo, minVersion, shellVersion] = platform === "ios"
|
|
548
578
|
? ["lecodes-ios-sdk", SDK_REPO, SDK_MIN_VERSION, () => shellSdkVersion(join(root, "ios"))] as const
|
|
549
579
|
: ["lecodes-android-sdk", ANDROID_SDK_REPO, ANDROID_SDK_MIN_VERSION, () => shellSdkVersionAndroid(join(root, "android"))] as const
|
|
550
580
|
const section = config[platform]!
|
|
551
|
-
const wanted = flagStr(args, "sdk")
|
|
552
581
|
let changed = false
|
|
553
582
|
|
|
554
583
|
// No sdk key = an old shell: its CLI-owned build file is the pin sync would keep.
|
|
@@ -583,41 +612,40 @@ const updateSdkPin = async (
|
|
|
583
612
|
return changed
|
|
584
613
|
}
|
|
585
614
|
|
|
615
|
+
export interface AppUpdateOptions { platform?: string, sdk?: string, check?: boolean, noPlugins?: boolean, noSync?: boolean }
|
|
616
|
+
|
|
586
617
|
/** `lecodes app update [ios|android]` — move a shell's pins forward, the way `app sync` moves
|
|
587
618
|
* its code: app.json ios.sdk / android.sdk → the newest published SDK release,
|
|
588
619
|
* plugins.lock.json → the newest tag of every plugin whose app.json spec doesn't name one
|
|
589
620
|
* (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
|
-
|
|
621
|
+
* the shell actually carries the new versions — `--check` reports and writes nothing.
|
|
622
|
+
* `lecodes update` runs it as its shells step. */
|
|
623
|
+
export const runAppUpdate = async (opts: AppUpdateOptions = {}): Promise<void> => {
|
|
592
624
|
const root = projectRoot()
|
|
593
625
|
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.")
|
|
626
|
+
const platform = mobilePlatform(opts.platform)
|
|
627
|
+
const { hasIos, hasAndroid } = shellsOf(root, config)
|
|
628
|
+
if (platform === "ios" && !hasIos) throw new CliError(NO_IOS_SHELL)
|
|
629
|
+
if (platform === "android" && !hasAndroid) throw new CliError(NO_ANDROID_SHELL)
|
|
630
|
+
if (!hasIos && !hasAndroid) throw new CliError(NO_IOS_SHELL)
|
|
603
631
|
const doIos = hasIos && platform !== "android"
|
|
604
632
|
const doAndroid = hasAndroid && platform !== "ios"
|
|
605
|
-
if (
|
|
633
|
+
if (opts.sdk && doIos && doAndroid) {
|
|
606
634
|
throw new CliError("--sdk is ambiguous with two shells — run `lecodes app update ios --sdk …` or `lecodes app update android --sdk …`.")
|
|
607
635
|
}
|
|
608
636
|
|
|
609
|
-
const checkOnly =
|
|
637
|
+
const checkOnly = !!opts.check
|
|
610
638
|
let changed = false
|
|
611
639
|
|
|
612
640
|
// ---- SDK pins ----
|
|
613
|
-
if (doIos) changed = (await updateSdkPin(root, config!,
|
|
614
|
-
if (doAndroid) changed = (await updateSdkPin(root, config!,
|
|
641
|
+
if (doIos) changed = (await updateSdkPin(root, config!, opts.sdk, checkOnly, "ios")) || changed
|
|
642
|
+
if (doAndroid) changed = (await updateSdkPin(root, config!, opts.sdk, checkOnly, "android")) || changed
|
|
615
643
|
|
|
616
644
|
// ---- plugins ----
|
|
617
645
|
const lockPath = join(root, "plugins.lock.json")
|
|
618
646
|
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(lockPath)?.plugins ?? {}
|
|
619
647
|
const specs = Object.entries(config!.plugins ?? {})
|
|
620
|
-
if (specs.length > 0 && !
|
|
648
|
+
if (specs.length > 0 && !opts.noPlugins) {
|
|
621
649
|
const kept: Record<string, LockEntry> = {}
|
|
622
650
|
for (const [id, spec] of specs) {
|
|
623
651
|
const entry = lock[id]
|
|
@@ -650,50 +678,33 @@ const appUpdate = async (args: Args) => {
|
|
|
650
678
|
return
|
|
651
679
|
}
|
|
652
680
|
if (!changed) { success("Already up to date."); return }
|
|
653
|
-
if (
|
|
681
|
+
if (opts.noSync) {
|
|
654
682
|
success("Pins updated — run `lecodes app sync` to apply them to the shell.")
|
|
655
683
|
return
|
|
656
684
|
}
|
|
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.")
|
|
685
|
+
await runSync(platform)
|
|
691
686
|
}
|
|
692
687
|
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
688
|
+
const update = defineCommand({
|
|
689
|
+
name: "update",
|
|
690
|
+
summary: "Move the shell's pins forward (SDK release + plugin tags), then re-sync",
|
|
691
|
+
usage: "[ios|android]",
|
|
692
|
+
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.",
|
|
693
|
+
flags: {
|
|
694
|
+
sdk: str("pin that release instead of the newest one; 'latest' floats the pin", { value: "<v|latest>" }),
|
|
695
|
+
check: bool("report available updates, write nothing"),
|
|
696
|
+
"no-plugins": bool("only move the SDK pin"),
|
|
697
|
+
"no-sync": bool("update the pins without re-syncing"),
|
|
698
|
+
},
|
|
699
|
+
examples: ["lecodes app update", "lecodes app update --check", "lecodes app update ios --sdk 1.6.0"],
|
|
700
|
+
run: ({ args, flags }) => runAppUpdate({ platform: args[0], sdk: flags.sdk, check: flags.check, noPlugins: flags["no-plugins"], noSync: flags["no-sync"] }),
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
/* -------------------------------------- doctor --------------------------------------- */
|
|
704
|
+
|
|
705
|
+
type Check = (ok: boolean, label: string, hint?: string, fatal?: boolean) => void
|
|
706
|
+
|
|
707
|
+
const doctorIosChecks = async (root: string, config: AppConfig, check: Check) => {
|
|
697
708
|
let xcodeVersion = ""
|
|
698
709
|
try { xcodeVersion = runTool(root, "xcodebuild", ["-version"]).split("\n")[0] } catch { /* absent */ }
|
|
699
710
|
check(xcodeVersion !== "", `Xcode CLI (${xcodeVersion || "not found"})`, "install Xcode + command line tools")
|
|
@@ -773,124 +784,221 @@ const doctorIosChecks = async (
|
|
|
773
784
|
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
785
|
}
|
|
775
786
|
|
|
776
|
-
const
|
|
787
|
+
const doctor = defineCommand({
|
|
788
|
+
name: "doctor",
|
|
789
|
+
summary: "Preflight checks for building / uploading the shells",
|
|
790
|
+
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.",
|
|
791
|
+
examples: ["lecodes app doctor"],
|
|
792
|
+
run: async () => {
|
|
793
|
+
const root = projectRoot()
|
|
794
|
+
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
795
|
+
const { hasIos, hasAndroid } = shellsOf(root, config)
|
|
796
|
+
if (!hasIos && !hasAndroid) throw new CliError(NO_IOS_SHELL)
|
|
797
|
+
let failed = 0
|
|
798
|
+
const check: Check = (ok, label, hint = "", fatal = true) => {
|
|
799
|
+
log(`${ok ? c.green("✓") : (fatal ? c.red("✗") : c.yellow("!"))} ${label}${ok || !hint ? "" : c.dim(" — " + hint)}`)
|
|
800
|
+
if (!ok && fatal) failed++
|
|
801
|
+
}
|
|
777
802
|
|
|
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
|
-
}
|
|
803
|
+
if (config?.icon) {
|
|
804
|
+
check(existsSync(join(root, config.icon)), `Icon master ${config.icon}`, `app.json "icon" points at a missing file — sync will fail`)
|
|
805
|
+
}
|
|
806
|
+
if (hasIos) await doctorIosChecks(root, config!, check)
|
|
807
|
+
if (hasAndroid) {
|
|
808
|
+
await doctorAndroidChecks(root, config!, check)
|
|
809
|
+
if (!hasIos) {
|
|
810
|
+
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
|
|
811
|
+
const wanted = Object.keys(config!.plugins ?? {})
|
|
812
|
+
const inSync = wanted.every(id => lock[id]) && Object.keys(lock).every(id => wanted.includes(id))
|
|
813
|
+
check(wanted.length === 0 || inSync, "Plugins synced", "run `lecodes app sync`")
|
|
814
|
+
}
|
|
815
|
+
}
|
|
810
816
|
|
|
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
|
-
}
|
|
817
|
+
log("")
|
|
818
|
+
if (failed > 0) throw new CliError(`${failed} check(s) failed.`)
|
|
819
|
+
success("Ready to build.")
|
|
820
|
+
},
|
|
821
|
+
})
|
|
856
822
|
|
|
857
|
-
/*
|
|
823
|
+
/* ---------------------------------- build & upload ----------------------------------- */
|
|
824
|
+
|
|
825
|
+
const exportOptionsPlist = (method: string, teamId: string, destination?: "upload"): string => `<?xml version="1.0" encoding="UTF-8"?>
|
|
826
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
827
|
+
<plist version="1.0">
|
|
828
|
+
<dict>
|
|
829
|
+
<key>method</key>
|
|
830
|
+
<string>${method}</string>
|
|
831
|
+
<key>signingStyle</key>
|
|
832
|
+
<string>automatic</string>${teamId ? `
|
|
833
|
+
<key>teamID</key>
|
|
834
|
+
<string>${teamId}</string>` : ""}${destination ? `
|
|
835
|
+
<key>destination</key>
|
|
836
|
+
<string>${destination}</string>` : ""}
|
|
837
|
+
</dict>
|
|
838
|
+
</plist>
|
|
839
|
+
`
|
|
858
840
|
|
|
859
|
-
|
|
860
|
-
* Studio). No platform: open whichever single shell exists. */
|
|
861
|
-
const appOpen = async (args: Args) => {
|
|
841
|
+
const requireShell = (): { root: string, config: AppConfig } => {
|
|
862
842
|
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.`)
|
|
843
|
+
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
844
|
+
if (!config?.ios || !existsSync(join(root, "ios"))) throw new CliError(NO_IOS_SHELL)
|
|
845
|
+
return { root, config }
|
|
881
846
|
}
|
|
882
847
|
|
|
848
|
+
const ARCHIVE_PATH = "ios/build/App.xcarchive"
|
|
849
|
+
|
|
850
|
+
const iosBuild = defineCommand({
|
|
851
|
+
name: "ios",
|
|
852
|
+
summary: "Sync, archive and export the .ipa (automatic signing)",
|
|
853
|
+
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.",
|
|
854
|
+
flags: {
|
|
855
|
+
configuration: str("build configuration", { value: "<c>", default: "Release" }),
|
|
856
|
+
method: str("export method", { value: "<m>", default: "app-store-connect" }),
|
|
857
|
+
"no-export": bool("stop after the archive"),
|
|
858
|
+
"no-sync": bool("skip the `lecodes app sync` step"),
|
|
859
|
+
},
|
|
860
|
+
examples: ["lecodes app build ios", "lecodes app build ios --method ad-hoc"],
|
|
861
|
+
run: async ({ flags }) => {
|
|
862
|
+
const { root, config } = requireShell()
|
|
863
|
+
const configuration = flags.configuration
|
|
864
|
+
const method = flags.method
|
|
865
|
+
|
|
866
|
+
if (!flags["no-sync"]) await runSync("ios")
|
|
867
|
+
|
|
868
|
+
note(`Archiving (${configuration})… this takes a few minutes`)
|
|
869
|
+
runTool(root, "xcodebuild", [
|
|
870
|
+
"-project", "ios/App.xcodeproj", "-scheme", "App",
|
|
871
|
+
"-destination", "generic/platform=iOS", "-configuration", configuration,
|
|
872
|
+
"archive", "-archivePath", ARCHIVE_PATH, "-allowProvisioningUpdates",
|
|
873
|
+
])
|
|
874
|
+
success(`Archive: ios/build/App.xcarchive`)
|
|
875
|
+
|
|
876
|
+
if (flags["no-export"]) return
|
|
877
|
+
const optionsPath = join(root, "ios", "build", "ExportOptions.plist")
|
|
878
|
+
writeFileSync(optionsPath, exportOptionsPlist(method, config.ios!.teamId ?? ""))
|
|
879
|
+
note(`Exporting (.ipa, method ${method})…`)
|
|
880
|
+
runTool(root, "xcodebuild", [
|
|
881
|
+
"-exportArchive", "-archivePath", ARCHIVE_PATH,
|
|
882
|
+
"-exportOptionsPlist", optionsPath, "-exportPath", "ios/build/export",
|
|
883
|
+
"-allowProvisioningUpdates",
|
|
884
|
+
])
|
|
885
|
+
success("Export: ios/build/export/ — upload with `lecodes app upload ios`")
|
|
886
|
+
},
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
const build = defineCommand({
|
|
890
|
+
name: "build",
|
|
891
|
+
summary: "Build a shippable artifact: ios (.ipa) | android (.apk/.aab) | desktop (portable folder / .app)",
|
|
892
|
+
// The desktop node is `lecodes desktop build` verbatim (same flags, same run); only its name
|
|
893
|
+
// differs here, where the platform is the word after `build`.
|
|
894
|
+
commands: [iosBuild, androidBuild, { ...desktopBuild, name: "desktop" }],
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
const iosUpload = defineCommand({
|
|
898
|
+
name: "ios",
|
|
899
|
+
summary: "Upload the archive to App Store Connect (then bumps ios.buildNumber)",
|
|
900
|
+
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.",
|
|
901
|
+
flags: {
|
|
902
|
+
"key-path": str("App Store Connect API key (AuthKey_XXXX.p8)", { value: "<p8>" }),
|
|
903
|
+
"key-id": str("the key's id", { value: "<id>" }),
|
|
904
|
+
"issuer-id": str("the key's issuer id", { value: "<id>" }),
|
|
905
|
+
},
|
|
906
|
+
examples: ["lecodes app upload ios --key-path ~/AuthKey_ABC123.p8 --key-id ABC123 --issuer-id 1234-…", "lecodes app upload ios"],
|
|
907
|
+
run: ({ flags }) => {
|
|
908
|
+
const { root, config } = requireShell()
|
|
909
|
+
if (!existsSync(join(root, ARCHIVE_PATH))) {
|
|
910
|
+
throw new CliError("No archive at ios/build/App.xcarchive — run `lecodes app build ios` first.")
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// App Store Connect API key: flags on first use, then ~/.lecodes/asc.json.
|
|
914
|
+
const ascPath = join(homedir(), ".lecodes", "asc.json")
|
|
915
|
+
const stored = readJson<{ keyPath?: string, keyId?: string, issuerId?: string }>(ascPath) ?? {}
|
|
916
|
+
const keyPath = flags["key-path"] ?? stored.keyPath
|
|
917
|
+
const keyId = flags["key-id"] ?? stored.keyId
|
|
918
|
+
const issuerId = flags["issuer-id"] ?? stored.issuerId
|
|
919
|
+
if (!keyPath || !keyId || !issuerId) {
|
|
920
|
+
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)")
|
|
921
|
+
}
|
|
922
|
+
if (!existsSync(keyPath)) throw new CliError(`API key file not found: ${keyPath}`)
|
|
923
|
+
if (flags["key-path"]) {
|
|
924
|
+
mkdirSync(dirname(ascPath), { recursive: true })
|
|
925
|
+
writeJson(ascPath, { keyPath, keyId, issuerId })
|
|
926
|
+
note("Stored the key reference in ~/.lecodes/asc.json")
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const optionsPath = join(root, "ios", "build", "UploadOptions.plist")
|
|
930
|
+
writeFileSync(optionsPath, exportOptionsPlist("app-store-connect", config.ios!.teamId ?? "", "upload"))
|
|
931
|
+
note("Uploading to App Store Connect… (processing continues on Apple's side)")
|
|
932
|
+
runTool(root, "xcodebuild", [
|
|
933
|
+
"-exportArchive", "-archivePath", ARCHIVE_PATH,
|
|
934
|
+
"-exportOptionsPlist", optionsPath, "-exportPath", "ios/build/upload",
|
|
935
|
+
"-authenticationKeyPath", resolve(keyPath),
|
|
936
|
+
"-authenticationKeyID", keyId,
|
|
937
|
+
"-authenticationKeyIssuerID", issuerId,
|
|
938
|
+
"-allowProvisioningUpdates",
|
|
939
|
+
])
|
|
940
|
+
|
|
941
|
+
// A successful upload consumes this build number — bump for the next one.
|
|
942
|
+
const configPath = join(root, "app.json")
|
|
943
|
+
const fresh = readJson<AppConfig>(configPath)!
|
|
944
|
+
fresh.ios!.buildNumber = (fresh.ios!.buildNumber ?? 1) + 1
|
|
945
|
+
writeJson(configPath, fresh)
|
|
946
|
+
success(`Uploaded build ${(fresh.ios!.buildNumber ?? 2) - 1} — check App Store Connect / TestFlight once processing finishes.`)
|
|
947
|
+
note(`ios.buildNumber bumped to ${fresh.ios!.buildNumber} for the next build.`)
|
|
948
|
+
},
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
const upload = defineCommand({
|
|
952
|
+
name: "upload",
|
|
953
|
+
summary: "Upload a build to the store: ios (App Store Connect)",
|
|
954
|
+
commands: [
|
|
955
|
+
iosUpload,
|
|
956
|
+
// Not wired yet — kept as a node so `app upload android` says what to do instead of "unknown command".
|
|
957
|
+
defineCommand({
|
|
958
|
+
name: "android",
|
|
959
|
+
summary: "Play upload (not wired yet)",
|
|
960
|
+
hidden: true,
|
|
961
|
+
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).") },
|
|
962
|
+
}),
|
|
963
|
+
],
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
/* --------------------------------------- open ---------------------------------------- */
|
|
967
|
+
|
|
968
|
+
const open = defineCommand({
|
|
969
|
+
name: "open",
|
|
970
|
+
summary: "Open the native shell in its IDE (Xcode / Android Studio)",
|
|
971
|
+
usage: "[ios|android]",
|
|
972
|
+
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).",
|
|
973
|
+
examples: ["lecodes app open", "lecodes app open android"],
|
|
974
|
+
run: async ({ args }) => {
|
|
975
|
+
const root = projectRoot()
|
|
976
|
+
let platform = args[0]
|
|
977
|
+
if (!platform) {
|
|
978
|
+
const hasIos = existsSync(join(root, "ios"))
|
|
979
|
+
const hasAndroid = existsSync(join(root, "android"))
|
|
980
|
+
if (hasIos && hasAndroid) throw new CliError("Both shells exist — `lecodes app open ios` or `lecodes app open android`.")
|
|
981
|
+
platform = hasAndroid ? "android" : "ios"
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
if (platform === "ios") {
|
|
985
|
+
const proj = join(root, "ios", "App.xcodeproj")
|
|
986
|
+
if (!existsSync(proj)) throw new CliError(NO_IOS_SHELL)
|
|
987
|
+
if (process.platform !== "darwin") throw new CliError("Opening Xcode requires macOS.")
|
|
988
|
+
execFileSync("open", [proj], { stdio: "ignore" })
|
|
989
|
+
success("Opened ios/App.xcodeproj in Xcode.")
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
if (platform === "android") return openAndroid()
|
|
993
|
+
throw new CliError(`Unknown platform "${platform}" — use ios or android.`)
|
|
994
|
+
},
|
|
995
|
+
})
|
|
996
|
+
|
|
883
997
|
/* --------------------------------------- entry --------------------------------------- */
|
|
884
998
|
|
|
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
|
-
}
|
|
999
|
+
export default defineCommand({
|
|
1000
|
+
name: "app",
|
|
1001
|
+
summary: "Native apps: iOS / Android shells and the portable desktop build (init, sync, update, build, upload)",
|
|
1002
|
+
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.",
|
|
1003
|
+
commands: [init, sync, update, plugin, open, doctor, build, keystore, upload],
|
|
1004
|
+
})
|