lecodes-cli 0.20.2 → 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.
Files changed (105) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +5918 -5020
  3. package/package.json +7 -5
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap-baked-lite.mat +6 -1
  7. package/runtime/materials/lightmap-baked.mat +6 -1
  8. package/runtime/materials/lightmap.mat +3 -0
  9. package/runtime/scene-harness.json +1 -1
  10. package/runtime/sdk-types.json +1 -1
  11. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +866 -0
  12. package/runtime/web/assets/{index-BMt7AnC5.js → index-Q06c6oHx.js} +1 -1
  13. package/runtime/web/embed.js +1 -1
  14. package/src/api.ts +1 -302
  15. package/src/cli/args.ts +37 -0
  16. package/src/cli/command.ts +113 -0
  17. package/src/cli/errors.ts +13 -0
  18. package/src/cli/help.ts +133 -0
  19. package/src/cli/index.ts +7 -0
  20. package/src/cli/output.ts +102 -0
  21. package/src/cli/run.ts +106 -0
  22. package/src/commands/{appAndroid.ts → app/android.ts} +451 -42
  23. package/src/commands/{appDesktop.ts → app/desktop.ts} +63 -32
  24. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +24 -18
  25. package/src/commands/{appIcon.ts → app/icon.ts} +2 -2
  26. package/src/commands/{app.ts → app/index.ts} +397 -289
  27. package/src/commands/{appShared.ts → app/shared.ts} +30 -39
  28. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +62 -28
  29. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  30. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +1 -1
  31. package/src/commands/assets.ts +23 -21
  32. package/src/commands/clone.ts +48 -51
  33. package/src/commands/compile.ts +62 -88
  34. package/src/commands/create.ts +46 -47
  35. package/src/commands/design/comments.ts +361 -0
  36. package/src/commands/design/context.ts +101 -0
  37. package/src/commands/design/index.ts +391 -0
  38. package/src/commands/design/snapshot.ts +134 -0
  39. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  40. package/src/commands/desktop.ts +90 -82
  41. package/src/commands/dev.ts +201 -176
  42. package/src/commands/diff.ts +39 -51
  43. package/src/commands/index.ts +60 -0
  44. package/src/commands/{init.ts → init/index.ts} +199 -191
  45. package/src/commands/install.ts +80 -67
  46. package/src/commands/lightmap.ts +203 -171
  47. package/src/commands/link.ts +87 -94
  48. package/src/commands/login.ts +25 -23
  49. package/src/commands/navmesh.ts +168 -138
  50. package/src/commands/pn.ts +201 -244
  51. package/src/commands/pull.ts +49 -56
  52. package/src/commands/push.ts +48 -54
  53. package/src/commands/render.ts +158 -99
  54. package/src/commands/scene.ts +58 -55
  55. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  56. package/src/commands/{shadersNew.ts → shaders/new.ts} +2 -2
  57. package/src/commands/shared.ts +79 -0
  58. package/src/commands/status.ts +22 -26
  59. package/src/commands/test.ts +398 -371
  60. package/src/commands/thumbs.ts +185 -175
  61. package/src/commands/update/index.ts +226 -0
  62. package/src/commands/{types.ts → update/types.ts} +31 -22
  63. package/src/compile/collect.ts +5 -5
  64. package/src/compile/collectLocal.ts +3 -3
  65. package/src/compile/designCompile.ts +3 -3
  66. package/src/compile/headlessBundle.ts +133 -129
  67. package/src/compile/projectCompile.ts +2 -2
  68. package/src/compile/sceneCompile.ts +10 -8
  69. package/src/compile/screenEntry.ts +123 -127
  70. package/src/compile/shaders.ts +3 -3
  71. package/src/dev/androidDev.ts +1 -1
  72. package/src/dev/devServer.ts +3 -3
  73. package/src/dev/webRunner.ts +1 -1
  74. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +1 -1
  75. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +6 -5
  76. package/src/{desktopScript.ts → hosts/desktopScript.ts} +1 -1
  77. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  78. package/src/{matcTool.ts → hosts/matcTool.ts} +1 -1
  79. package/src/{peerInstall.ts → hosts/peerInstall.ts} +2 -2
  80. package/src/{releases.ts → hosts/releases.ts} +1 -1
  81. package/src/index.ts +32 -480
  82. package/src/platform/api.ts +302 -0
  83. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  84. package/src/{config.ts → platform/config.ts} +1 -1
  85. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  86. package/src/{projectEnv.ts → project/env.ts} +33 -2
  87. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  88. package/src/{manifest.ts → project/manifest.ts} +1 -1
  89. package/src/{project.ts → project/materialize.ts} +2 -2
  90. package/src/project/paths.ts +20 -0
  91. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  92. package/src/{types.ts → project/types.ts} +0 -0
  93. package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
  94. package/src/commands/design.ts +0 -846
  95. package/src/commands/update.ts +0 -211
  96. package/src/util.ts +0 -146
  97. /package/src/commands/{projectTemplates.ts → init/templates.ts} +0 -0
  98. /package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +0 -0
  99. /package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +0 -0
  100. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  101. /package/src/{lecodes-renderer.d.ts → declarations/lecodes-renderer.d.ts} +0 -0
  102. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  103. /package/src/{peers.ts → hosts/peers.ts} +0 -0
  104. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
  105. /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 "../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"
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 "./appTemplates"
14
+ } from "./templates/ios"
15
15
  import {
16
- FIRST_PARTY_PLUGINS, SDK_VARIANTS, cmpVersion, detectEngines, ensureShellIgnored,
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 "./appShared"
19
+ } from "./shared"
20
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"
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 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.
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 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
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
- 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
- }
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 = flagStr(args, "variant") ?? existing.ios?.variant ?? "auto"
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 = flagStr(args, "sdk") ?? existing.ios?.sdk
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 = flagStr(args, "name") ?? existing.name ?? basename(root)
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: flagStr(args, "bundle-id") ?? existing.ios?.bundleId ?? `com.example.${slugify(name)}`,
119
- teamId: flagStr(args, "team") ?? existing.ios?.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
- /* --------------------------------------- sync --------------------------------------- */
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 appSync = async (args: Args) => {
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 = 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
- }
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("No iOS shell here — run `lecodes app init ios` first.")
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({ _: [], flags: { o: join(resourcesDir, "app.js") } }, { remotePatterns })
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 appPlugin = async (args: Args) => {
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
- if (sub === "list" || sub === undefined) {
470
- const entries = Object.entries(config.plugins)
471
- if (entries.length === 0) { log("No plugins installed."); return }
472
- const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
473
- for (const [id, spec] of entries) {
474
- const pin = lock[id] ? c.dim(` ${lock[id].repo} ${lock[id].tag} (${lock[id].commit.slice(0, 7)})`) : c.dim(" (not synced)")
475
- log(`${c.bold(id)} ${spec}${pin}`)
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
- if (sub === "remove") {
500
- if (!(id in config.plugins)) throw new CliError(`Plugin "${id}" is not installed.`)
501
- delete config.plugins[id]
502
- writeJson(configPath, config)
503
- log(`${c.yellow("removed")} ${id}`)
504
- note("Run `lecodes app sync` to drop its sources from the shell.")
505
- return
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
- throw new CliError(`Unknown subcommand "plugin ${sub}" — use list, add or remove.`)
536
+ writeJson(configPath, config)
537
+ note("Run `lecodes app sync` to vendor it into the shell.")
509
538
  }
510
539
 
511
- /* ---------------------------------- build & upload ----------------------------------- */
512
-
513
- const exportOptionsPlist = (method: string, teamId: string, destination?: "upload"): string => `<?xml version="1.0" encoding="UTF-8"?>
514
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
515
- <plist version="1.0">
516
- <dict>
517
- <key>method</key>
518
- <string>${method}</string>
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, args: Args, checkOnly: boolean,
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
- const appUpdate = async (args: Args) => {
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 = args._[1]
595
- if (platform !== undefined && platform !== "ios" && platform !== "android") {
596
- throw new CliError(`Unknown platform "${platform}" — use ios or android.`)
597
- }
598
- const hasIos = !!config?.ios && existsSync(join(root, "ios"))
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 (flagStr(args, "sdk") && doIos && doAndroid) {
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 = flagBool(args, "check")
637
+ const checkOnly = !!opts.check
610
638
  let changed = false
611
639
 
612
640
  // ---- SDK pins ----
613
- if (doIos) changed = (await updateSdkPin(root, config!, args, checkOnly, "ios")) || changed
614
- if (doAndroid) changed = (await updateSdkPin(root, config!, args, checkOnly, "android")) || changed
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 && !flagBool(args, "no-plugins")) {
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 (flagBool(args, "no-sync")) {
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 appSync({ _: platform ? ["sync", platform] : [], flags: {} })
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 doctorIosChecks = async (
694
- root: string, config: AppConfig,
695
- check: (ok: boolean, label: string, hint?: string, fatal?: boolean) => void,
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 ARCHIVE_PATH = "ios/build/App.xcarchive"
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
- const appBuild = async (args: Args) => {
779
- const platform = args._[1]
780
- if (platform === "android") return buildAndroid(args)
781
- if (platform === "desktop") return buildDesktop(args)
782
- if (platform !== undefined && platform !== "ios") {
783
- throw new CliError(`Unknown platform "${platform}" — use ios, android or desktop.`)
784
- }
785
- const { root, config } = requireShell()
786
- const configuration = flagStr(args, "configuration") ?? "Release"
787
- const method = flagStr(args, "method") ?? "app-store-connect"
788
-
789
- if (!flagBool(args, "no-sync")) await appSync({ _: ["sync", "ios"], flags: {} })
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
- const appUpload = async (args: Args) => {
812
- if (args._[1] === "android") {
813
- 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).")
814
- }
815
- const { root, config } = requireShell()
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
- /* --------------------------------------- open ---------------------------------------- */
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
- /** `lecodes app open [ios|android]` — open the native shell in its IDE (Xcode / Android
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
- let platform = args._[1]
864
- if (!platform) {
865
- const hasIos = existsSync(join(root, "ios"))
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 const app = async (args: Args) => {
886
- const sub = args._[0]
887
- if (sub === "init") return appInit(args)
888
- if (sub === "sync") return appSync(args)
889
- if (sub === "update") return appUpdate(args)
890
- if (sub === "plugin") return appPlugin(args)
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
+ })