@lynxship/cli 0.1.4 → 0.1.7

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 (57) hide show
  1. package/README.md +90 -2
  2. package/dist/android-build.d.ts +1 -0
  3. package/dist/android-build.d.ts.map +1 -1
  4. package/dist/android-build.js +12 -19
  5. package/dist/android-host.d.ts +12 -0
  6. package/dist/android-host.d.ts.map +1 -0
  7. package/dist/android-host.js +70 -0
  8. package/dist/bundle-build.d.ts +7 -0
  9. package/dist/bundle-build.d.ts.map +1 -0
  10. package/dist/bundle-build.js +24 -0
  11. package/dist/guidance.d.ts +6 -0
  12. package/dist/guidance.d.ts.map +1 -0
  13. package/dist/guidance.js +354 -0
  14. package/dist/index.js +349 -93
  15. package/dist/ios-build.d.ts +1 -0
  16. package/dist/ios-build.d.ts.map +1 -1
  17. package/dist/ios-build.js +38 -12
  18. package/dist/ios-host.d.ts +17 -0
  19. package/dist/ios-host.d.ts.map +1 -0
  20. package/dist/ios-host.js +93 -0
  21. package/dist/ui/components.d.ts +2 -0
  22. package/dist/ui/components.d.ts.map +1 -1
  23. package/dist/ui/components.js +30 -16
  24. package/dist/ui/index.d.ts +3 -1
  25. package/dist/ui/index.d.ts.map +1 -1
  26. package/dist/ui/index.js +6 -1
  27. package/package.json +5 -4
  28. package/templates/android-host/app/build.gradle +38 -0
  29. package/templates/android-host/app/proguard-rules.pro +2 -0
  30. package/templates/android-host/app/src/main/AndroidManifest.xml +20 -0
  31. package/templates/android-host/app/src/main/java/template/LynxShipApplication.java +13 -0
  32. package/templates/android-host/app/src/main/java/template/MainActivity.java +19 -0
  33. package/templates/android-host/app/src/main/java/template/ProjectTemplateProvider.java +32 -0
  34. package/templates/android-host/app/src/main/res/values/strings.xml +3 -0
  35. package/templates/android-host/app/src/main/res/values/themes.xml +6 -0
  36. package/templates/android-host/build.gradle +3 -0
  37. package/templates/android-host/gradle/wrapper/gradle-wrapper.jar +0 -0
  38. package/templates/android-host/gradle/wrapper/gradle-wrapper.properties +7 -0
  39. package/templates/android-host/gradle.properties +3 -0
  40. package/templates/android-host/gradlew +185 -0
  41. package/templates/android-host/gradlew.bat +89 -0
  42. package/templates/android-host/settings.gradle +20 -0
  43. package/templates/ios-host/ExportOptions.plist +16 -0
  44. package/templates/ios-host/Podfile +42 -0
  45. package/templates/ios-host/__IOS_TARGET_NAME__/AppDelegate.swift +11 -0
  46. package/templates/ios-host/__IOS_TARGET_NAME__/Assets.xcassets/AccentColor.colorset/Contents.json +11 -0
  47. package/templates/ios-host/__IOS_TARGET_NAME__/Assets.xcassets/AppIcon.appiconset/Contents.json +13 -0
  48. package/templates/ios-host/__IOS_TARGET_NAME__/Assets.xcassets/Contents.json +6 -0
  49. package/templates/ios-host/__IOS_TARGET_NAME__/Base.lproj/LaunchScreen.storyboard +25 -0
  50. package/templates/ios-host/__IOS_TARGET_NAME__/Base.lproj/Main.storyboard +24 -0
  51. package/templates/ios-host/__IOS_TARGET_NAME__/DemoLynxProvider.swift +18 -0
  52. package/templates/ios-host/__IOS_TARGET_NAME__/Hello-Lynx-Bridging-Header.h +4 -0
  53. package/templates/ios-host/__IOS_TARGET_NAME__/Info.plist +25 -0
  54. package/templates/ios-host/__IOS_TARGET_NAME__/SceneDelegate.swift +49 -0
  55. package/templates/ios-host/__IOS_TARGET_NAME__/ViewController.swift +22 -0
  56. package/templates/ios-host/__IOS_TARGET_NAME__.xcodeproj/project.pbxproj +452 -0
  57. package/templates/ios-host/sync-bundle.mjs +11 -0
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
- import { dirname, join, resolve } from "node:path";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
6
  import { BuildOrchestrator } from "@lynxship/build-orchestrator";
7
7
  import { JsonRepository } from "@lynxship/db";
8
8
  import { assert, createId, sha256, } from "@lynxship/contracts";
@@ -10,6 +10,8 @@ import { createSigningKey, signManifest, } from "@lynxship/signing";
10
10
  import { AppStoreConnectApiProvider, GooglePlayApiProvider, SubmissionService, } from "@lynxship/submit";
11
11
  import { DEFAULT_CONFIG, loadConfig, platformValue, } from "./config.js";
12
12
  import { hasAndroidHost, isSupportedAndroidPlatform, runRealAndroidBuild, } from "./android-build.js";
13
+ import { initializeAndroidHost, suggestedAndroidApplicationId, } from "./android-host.js";
14
+ import { initializeIosHost, suggestedIosBundleIdentifier } from "./ios-host.js";
13
15
  import { hasIosHost, runRealIosBuild } from "./ios-build.js";
14
16
  import { configureAndroid, configureAppStoreConnect, configureGooglePlay, configureR2, } from "./configure.js";
15
17
  import { fetchOtaPublicKey, publishOtaRelease, rollbackOtaRelease, submitRealArtifact, } from "./remote.js";
@@ -21,7 +23,10 @@ import { inspectAutolink, requireAutolinkReady } from "./autolink.js";
21
23
  import { assertCompatibleBinaryBuild, inspectRuntimeFingerprint, } from "./runtime-fingerprint.js";
22
24
  import { inspectOtaHost } from "./ota-doctor.js";
23
25
  import { otaAssetName, otaAssetPaths } from "./ota-assets.js";
26
+ import { prompt } from "./prompt.js";
24
27
  import { commandExists, packageManagerCommand, runProcess, runRspeedy, } from "./process-runner.js";
28
+ import { guidanceForError } from "./guidance.js";
29
+ import { buildLynxBundle } from "./bundle-build.js";
25
30
  const rawArgs = process.argv.slice(2);
26
31
  const args = [...rawArgs];
27
32
  const ui = createCliUi(rawArgs);
@@ -52,6 +57,10 @@ function flag(name, fallback = null) {
52
57
  const index = args.indexOf(name);
53
58
  return index >= 0 ? (args[index + 1] ?? "true") : fallback;
54
59
  }
60
+ async function assertInteractivePrompt(label, fallback, optionName) {
61
+ assert(ui.interactive, "CLI_INTERACTIVE_REQUIRED", `Pass ${label.toLowerCase()} with ${optionName} in non-interactive mode.`);
62
+ return prompt(label, fallback);
63
+ }
55
64
  function printValue(value, view) {
56
65
  if (json) {
57
66
  console.log(JSON.stringify(typeof value === "string" ? { result: value } : value));
@@ -171,9 +180,134 @@ async function loadState() {
171
180
  return { state, repository, builds, submissions };
172
181
  }
173
182
  async function saveState(state, repository, builds, submissions) {
174
- state.builds = builds.list();
175
- state.submissions = submissions.list();
176
- await repository.write(state);
183
+ const next = stateSaveQueue.then(async () => {
184
+ state.builds = builds.list();
185
+ state.submissions = submissions.list();
186
+ await repository.write(state);
187
+ });
188
+ stateSaveQueue = next.catch(() => undefined);
189
+ await next;
190
+ }
191
+ let stateSaveQueue = Promise.resolve();
192
+ async function executeBuild(options, showResult = true) {
193
+ const { config, profile, platform, skipUpload, wait, local, state, repository, builds, submissions, progress: sharedProgress, progressPrefix, onProgress, onEvent, skipBundleBuild, } = options;
194
+ await requireAutolinkReady(root, platform);
195
+ const runtime = await inspectRuntimeFingerprint(root, platform, config);
196
+ const job = await builds.create({
197
+ projectId: configuredProjectId(config),
198
+ organizationId: "local_org",
199
+ platform,
200
+ profile,
201
+ sourceHash: createHash("sha256").update(root).digest("hex"),
202
+ runtimeVersion: runtime.value,
203
+ runtimeInputs: runtime.inputs,
204
+ });
205
+ ui.info(`Using profile: ${profile} · platform: ${platform}`);
206
+ const ownsProgress = !sharedProgress;
207
+ const progress = sharedProgress ??
208
+ ui.progress(platform === "android" ? "Android build" : "iOS build");
209
+ const prefix = progressPrefix ? `${progressPrefix} · ` : "";
210
+ const reportEvent = (message) => {
211
+ if (onEvent)
212
+ onEvent(message);
213
+ else
214
+ progress.event(`${prefix}${message}`);
215
+ };
216
+ const reportProgress = (value, label) => {
217
+ if (onProgress)
218
+ onProgress(value, label);
219
+ else
220
+ progress.update(value, label ? `${prefix}${label}` : undefined);
221
+ };
222
+ try {
223
+ reportProgress(undefined, "Preparing build pipeline…");
224
+ if (wait) {
225
+ if (platform === "android" && !isSupportedAndroidPlatform() && !local)
226
+ assert(false, "ANDROID_PLATFORM_UNSUPPORTED", "Android builds are supported only on Linux, macOS and Windows.");
227
+ const realAndroid = platform === "android" &&
228
+ isSupportedAndroidPlatform() &&
229
+ (await hasAndroidHost(root));
230
+ const realIos = platform === "ios" && hasIosHost(root, config.build?.[profile]);
231
+ if (platform === "ios" && !realIos && !local)
232
+ assert(false, "IOS_HOST_REQUIRED", "A macOS Xcode host is required for a real iOS build. No local fake iOS build is created.");
233
+ if (platform === "android" && !realAndroid && !local)
234
+ assert(false, "ANDROID_HOST_REQUIRED", "This project has no Android Gradle host. For live development, run `lynxship dev` and scan the QR code with Lynx Explorer; for an APK/AAB, integrate the official Lynx Android host under `android/` (including android/gradlew). `--local` is only for contract tests and does not create an APK.");
235
+ if (realAndroid) {
236
+ await runRealAndroidBuild(job, {
237
+ root,
238
+ profile: config.build?.[profile] ?? {},
239
+ uploadArtifacts: !skipUpload,
240
+ skipBundleBuild,
241
+ quiet: json,
242
+ onEvent: reportEvent,
243
+ onProgress: reportProgress,
244
+ });
245
+ }
246
+ else if (realIos) {
247
+ await runRealIosBuild(job, {
248
+ root,
249
+ profile: config.build?.[profile] ?? {},
250
+ uploadArtifacts: !skipUpload,
251
+ skipBundleBuild,
252
+ quiet: json,
253
+ onEvent: reportEvent,
254
+ onProgress: reportProgress,
255
+ });
256
+ }
257
+ else {
258
+ await builds.run(job.id);
259
+ }
260
+ }
261
+ reportProgress(100);
262
+ await saveState(state, repository, builds, submissions);
263
+ }
264
+ catch (error) {
265
+ await saveState(state, repository, builds, submissions);
266
+ if (error instanceof Error) {
267
+ Object.assign(error, { buildId: job.id });
268
+ }
269
+ throw error;
270
+ }
271
+ finally {
272
+ if (ownsProgress)
273
+ progress.stop();
274
+ }
275
+ const result = builds.get(job.id);
276
+ if (showResult) {
277
+ printValue(result, {
278
+ title: `${platform === "android" ? "Android" : "iOS"} build result`,
279
+ rows: [
280
+ { label: "Build ID", value: result.id, valueColor: "purple" },
281
+ { label: "Platform", value: result.platform, valueColor: "blue" },
282
+ { label: "Profile", value: result.profile, valueColor: "text" },
283
+ {
284
+ label: "Status",
285
+ value: result.state,
286
+ valueColor: result.state === "success" ? "green" : "yellow",
287
+ },
288
+ ],
289
+ done: result.state === "success"
290
+ ? "Build complete. Run lynxship submit to publish."
291
+ : "Build queued.",
292
+ });
293
+ if (result.state === "success" && result.artifact?.url)
294
+ ui.downloadArtifact(result.artifact.url, result.artifact.expiresAt);
295
+ }
296
+ return result;
297
+ }
298
+ async function buildSharedLynxBundle() {
299
+ const progress = ui.progress("Shared Lynx bundle");
300
+ try {
301
+ progress.update(undefined, "Building Lynx bundle once for Android and iOS…");
302
+ await buildLynxBundle(root, {
303
+ quiet: json,
304
+ onOutput: (message) => progress.event(message),
305
+ });
306
+ progress.update(100, "Shared Lynx bundle ready");
307
+ }
308
+ finally {
309
+ progress.stop();
310
+ }
177
311
  }
178
312
  async function initSelfHost() {
179
313
  const directory = join(root, ".lynxship");
@@ -218,7 +352,7 @@ function helpText() {
218
352
  Commands:
219
353
  init Initialize or link a LynxShip project
220
354
  doctor Check the local toolchain and project
221
- dev Run the project's Rspeedy development server
355
+ dev Run Rspeedy dev with Lynx Explorer QR/HMR
222
356
  preview Preview the production Lynx bundle locally
223
357
  inspect Inspect Rspeedy/Rspack configuration
224
358
  profile Build with Rspack profiling enabled
@@ -232,17 +366,20 @@ Commands:
232
366
  build status <id> Show one build job
233
367
  build cancel <id> Cancel a build job
234
368
  build retry <id> Retry a failed build job
369
+ build all Build Android and iOS on macOS
235
370
  submit Submit the latest successful build
236
371
  update Upload and publish a signed OTA update
237
372
  update rollback Roll back an OTA channel to a previous release
238
373
  rollback Alias for update rollback
239
374
  self-host init Generate local self-host credentials
240
375
  storage configure Configure Cloudflare R2 and encrypted R2 credentials
376
+ android host init Create a minimal official Lynx Android host
377
+ ios host init Create a minimal official Lynx iOS/Xcode host
241
378
  android configure Configure or generate encrypted Android signing credentials
242
379
  store configure Configure Google Play or App Store Connect submission
243
380
 
244
381
  Build options:
245
- --platform <p> Target android or ios (default: android)
382
+ --platform <p> Target android, ios or all for build (default: android)
246
383
  --profile <name> Build profile (default: production)
247
384
  --no-wait Queue the build without executing it locally
248
385
  --no-upload Keep the signed artifact local and skip R2 (CI verification)
@@ -286,6 +423,8 @@ Global options:
286
423
  --banner Show the Braille LynxShip logo in a TTY
287
424
  --project-dir <path> Use a LynxShip project from any working directory
288
425
  --project-id <id> Project ID used by init
426
+ --application-id <id> Android package/application ID for host init
427
+ --bundle-identifier <id> iOS bundle identifier for host init
289
428
  --library-dir <path> Native library directory for autolink codegen
290
429
  --simulator Install an iOS .app on a simulator with simctl
291
430
  --help Show this complete command reference
@@ -365,6 +504,9 @@ async function runRspeedyCommand(subcommand, environment) {
365
504
  await initializeBuildProject();
366
505
  const forwarded = forwardedToolArgs(args);
367
506
  ui.info(`Running local Rspeedy ${subcommand}…`);
507
+ if (subcommand === "dev") {
508
+ ui.info("Lynx Explorer mode: no Android or iOS native host is required. Scan the QR code printed by Rspeedy; source changes reload automatically.");
509
+ }
368
510
  await runRspeedy(root, subcommand, forwarded, {
369
511
  env: environment,
370
512
  quiet: json,
@@ -564,7 +706,7 @@ async function main() {
564
706
  name: "package-manager-lockfile",
565
707
  ok: Boolean(lockfile),
566
708
  status: lockfile ? "pass" : "fail",
567
- value: lockfile ?? "missing · fix: npm install or pnpm install",
709
+ value: lockfile ?? "missing · fix: pnpm install (or npm install)",
568
710
  },
569
711
  {
570
712
  name: "lynxship.json",
@@ -588,7 +730,7 @@ async function main() {
588
730
  status: androidHost ? "pass" : "fail",
589
731
  value: androidHost
590
732
  ? "Gradle host found"
591
- : "missing · add an Android host with android/gradlew",
733
+ : "missing · fix: lynxship android host init --application-id com.example.myapp",
592
734
  },
593
735
  ]
594
736
  : []),
@@ -598,7 +740,7 @@ async function main() {
598
740
  status: configuration.r2 ? "pass" : "fail",
599
741
  value: configuration.r2
600
742
  ? "configured"
601
- : "run lynxship storage configure",
743
+ : "missing · fix: lynxship storage configure",
602
744
  },
603
745
  {
604
746
  name: doctorPlatform === "android" ? "android-signing" : "ios-host",
@@ -613,10 +755,10 @@ async function main() {
613
755
  value: doctorPlatform === "android"
614
756
  ? configuration.android
615
757
  ? "configured"
616
- : "run lynxship android configure"
758
+ : "missing · fix: lynxship android configure"
617
759
  : process.platform === "darwin" && hasIosHost(root)
618
760
  ? "Xcode host found"
619
- : "missing · fix: use macOS with Xcode",
761
+ : "missing · fix: use macOS, then lynxship ios host init --bundle-identifier com.example.myapp",
620
762
  },
621
763
  {
622
764
  name: `lynx-autolink-${doctorPlatform}`,
@@ -780,9 +922,88 @@ async function main() {
780
922
  });
781
923
  return;
782
924
  }
925
+ if (command === "ios") {
926
+ const subcommand = args.shift() ?? "host";
927
+ assert(subcommand === "host" && (args.shift() ?? "init") === "init", "CLI_IOS_HOST_COMMAND", "Use `lynxship ios host init` to create an iOS host.");
928
+ await initializeBuildProject();
929
+ const suggestedId = suggestedIosBundleIdentifier(root);
930
+ const bundleIdentifier = flag("--bundle-identifier") ??
931
+ (await assertInteractivePrompt("iOS bundle identifier", suggestedId, "--bundle-identifier"));
932
+ const result = await initializeIosHost(root, {
933
+ bundleIdentifier,
934
+ appName: basename(root),
935
+ });
936
+ ui.success(`iOS host created: ${result.directory}`);
937
+ printValue({
938
+ status: "created",
939
+ platform: "ios",
940
+ directory: result.directory,
941
+ bundleIdentifier: result.bundleIdentifier,
942
+ project: result.project,
943
+ scheme: result.scheme,
944
+ configUpdated: result.configUpdated,
945
+ }, {
946
+ title: "iOS host",
947
+ rows: [
948
+ {
949
+ label: "Bundle identifier",
950
+ value: result.bundleIdentifier,
951
+ valueColor: "blue",
952
+ },
953
+ {
954
+ label: "Xcode project",
955
+ value: result.project,
956
+ valueColor: "green",
957
+ },
958
+ { label: "Scheme", value: result.scheme, valueColor: "purple" },
959
+ {
960
+ label: "CocoaPods",
961
+ value: "Run pod install on macOS before the first build",
962
+ valueColor: "yellow",
963
+ },
964
+ ],
965
+ done: "iOS host is ready. Run lynxship doctor --platform ios on macOS.",
966
+ });
967
+ return;
968
+ }
783
969
  if (command === "android") {
970
+ const subcommand = args.shift() ?? "configure";
971
+ if (subcommand === "host") {
972
+ assert((args.shift() ?? "init") === "init", "CLI_ANDROID_HOST_COMMAND", "Use `lynxship android host init` to create an Android host.");
973
+ await initializeBuildProject();
974
+ const suggestedId = suggestedAndroidApplicationId(root);
975
+ const applicationId = flag("--application-id") ??
976
+ (await assertInteractivePrompt("Android application ID", suggestedId, "--application-id"));
977
+ const result = await initializeAndroidHost(root, {
978
+ applicationId,
979
+ appName: basename(root),
980
+ });
981
+ ui.success(`Android host created: ${result.directory}`);
982
+ printValue({
983
+ status: "created",
984
+ platform: "android",
985
+ directory: result.directory,
986
+ applicationId: result.applicationId,
987
+ }, {
988
+ title: "Android host",
989
+ rows: [
990
+ {
991
+ label: "Application ID",
992
+ value: result.applicationId,
993
+ valueColor: "blue",
994
+ },
995
+ {
996
+ label: "Gradle wrapper",
997
+ value: join(result.directory, "gradlew"),
998
+ valueColor: "green",
999
+ },
1000
+ ],
1001
+ done: "Android host is ready. Run lynxship doctor, then lynxship build.",
1002
+ });
1003
+ return;
1004
+ }
784
1005
  assert(ui.interactive, "CLI_INTERACTIVE_REQUIRED", "Run `lynxship android configure` in an interactive terminal");
785
- assert((args.shift() ?? "configure") === "configure", "CLI_ANDROID_COMMAND", "Only android configure is available");
1006
+ assert(subcommand === "configure", "CLI_ANDROID_COMMAND", "Only android configure is available");
786
1007
  ui.info("Configuring Android signing. Secret fields will stay invisible…");
787
1008
  const result = await configureAndroid(root);
788
1009
  ui.success(result.generated
@@ -1120,7 +1341,9 @@ async function main() {
1120
1341
  }
1121
1342
  assert(command === "build", "CLI_COMMAND", `Unknown command: ${command}`);
1122
1343
  const subcommand = args[0] && !args[0].startsWith("--") ? args.shift() : "create";
1123
- const platform = platformValue(flag("--platform", "android"));
1344
+ const platformArgument = flag("--platform", "android");
1345
+ const buildAll = subcommand === "all" || platformArgument === "all";
1346
+ const platform = buildAll ? "android" : platformValue(platformArgument);
1124
1347
  const skipUpload = args.includes("--no-upload");
1125
1348
  await requireOperationalConfiguration(platform, { requireR2: !skipUpload });
1126
1349
  if (subcommand === "list") {
@@ -1144,90 +1367,114 @@ async function main() {
1144
1367
  printValue(job);
1145
1368
  return;
1146
1369
  }
1147
- assert(subcommand === "create", "CLI_BUILD_COMMAND", `Unknown build command: ${subcommand}`);
1370
+ assert(subcommand === "create" || buildAll, "CLI_BUILD_COMMAND", `Unknown build command: ${subcommand}`);
1148
1371
  const config = await loadConfig(root);
1149
1372
  const profile = flag("--profile", "production");
1150
- await requireAutolinkReady(root, platform);
1151
- const runtime = await inspectRuntimeFingerprint(root, platform, config);
1152
- const job = await builds.create({
1153
- projectId: configuredProjectId(config),
1154
- organizationId: "local_org",
1155
- platform,
1156
- profile,
1157
- sourceHash: createHash("sha256").update(root).digest("hex"),
1158
- runtimeVersion: runtime.value,
1159
- runtimeInputs: runtime.inputs,
1160
- });
1161
- ui.info(`Using profile: ${profile} · platform: ${platform}`);
1162
- const progress = ui.progress("Build execution");
1163
- try {
1164
- progress.update(undefined, "Preparing build pipeline…");
1165
- if (!args.includes("--no-wait")) {
1166
- if (platform === "android" &&
1167
- !isSupportedAndroidPlatform() &&
1168
- !args.includes("--local"))
1169
- assert(false, "ANDROID_PLATFORM_UNSUPPORTED", "Android builds are supported only on Linux, macOS and Windows.");
1170
- const realAndroid = platform === "android" &&
1171
- isSupportedAndroidPlatform() &&
1172
- (await hasAndroidHost(root));
1173
- const realIos = platform === "ios" && hasIosHost(root, config.build?.[profile]);
1174
- if (platform === "ios" && !realIos && !args.includes("--local"))
1175
- assert(false, "IOS_HOST_REQUIRED", "A macOS Xcode host is required for a real iOS build. No local fake iOS build is created.");
1176
- if (platform === "android" && !realAndroid && !args.includes("--local"))
1177
- assert(false, "ANDROID_HOST_REQUIRED", "A real Android build requires an Android Gradle host. Add android/gradlew and the native Lynx host, or use --local for contract tests.");
1178
- if (realAndroid) {
1179
- await runRealAndroidBuild(job, {
1180
- root,
1181
- profile: config.build?.[profile] ?? {},
1182
- uploadArtifacts: !skipUpload,
1183
- quiet: json,
1184
- onEvent: (message) => progress.event(message),
1185
- onProgress: (value, label) => progress.update(value, label),
1186
- });
1187
- }
1188
- else if (realIos) {
1189
- await runRealIosBuild(job, {
1190
- root,
1191
- profile: config.build?.[profile] ?? {},
1192
- uploadArtifacts: !skipUpload,
1193
- quiet: json,
1194
- onEvent: (message) => progress.event(message),
1195
- onProgress: (value, label) => progress.update(value, label),
1196
- });
1197
- }
1198
- else {
1199
- await builds.run(job.id);
1200
- }
1201
- }
1202
- progress.update(100);
1203
- await saveState(state, repository, builds, submissions);
1373
+ const wait = !args.includes("--no-wait");
1374
+ const local = args.includes("--local");
1375
+ const platforms = buildAll ? ["android", "ios"] : [platform];
1376
+ if (buildAll && wait && !local) {
1377
+ assert(process.platform === "darwin", "BUILD_ALL_MACOS_REQUIRED", "A real build for both Android and iOS requires macOS locally. Use `lynxship build --platform android` on Windows/Linux, or run this command on a macOS CI worker.");
1378
+ assert(await hasAndroidHost(root), "ANDROID_HOST_REQUIRED", "The Android host is required for `lynxship build --platform all`.");
1379
+ assert(hasIosHost(root, config.build?.[profile]), "IOS_HOST_REQUIRED", "The iOS Xcode host is required for `lynxship build --platform all`.");
1204
1380
  }
1205
- catch (error) {
1206
- await saveState(state, repository, builds, submissions);
1207
- throw error;
1208
- }
1209
- finally {
1210
- progress.stop();
1381
+ if (!buildAll) {
1382
+ await executeBuild({
1383
+ config,
1384
+ profile,
1385
+ platform,
1386
+ skipUpload,
1387
+ wait,
1388
+ local,
1389
+ state,
1390
+ repository,
1391
+ builds,
1392
+ submissions,
1393
+ });
1394
+ return;
1211
1395
  }
1212
- const result = builds.get(job.id);
1213
- printValue(result, {
1214
- title: "Build result",
1215
- rows: [
1216
- { label: "Build ID", value: result.id, valueColor: "purple" },
1217
- { label: "Platform", value: result.platform, valueColor: "blue" },
1218
- { label: "Profile", value: result.profile, valueColor: "text" },
1219
- {
1220
- label: "Status",
1221
- value: result.state,
1222
- valueColor: result.state === "success" ? "green" : "yellow",
1223
- },
1224
- ],
1225
- done: result.state === "success"
1226
- ? "Build complete. Run lynxship submit to publish."
1227
- : "Build queued.",
1396
+ if (buildAll && wait && !local)
1397
+ await buildSharedLynxBundle();
1398
+ const progress = ui.progress("Android + iOS build");
1399
+ const progressValues = {
1400
+ android: 0,
1401
+ ios: 0,
1402
+ };
1403
+ const progressLabels = {
1404
+ android: "Starting Android build…",
1405
+ ios: "Starting iOS build…",
1406
+ };
1407
+ const platformName = (target) => target === "android" ? "Android" : "iOS";
1408
+ const outcomes = await Promise.allSettled(platforms.map((target) => executeBuild({
1409
+ config,
1410
+ profile,
1411
+ platform: target,
1412
+ skipUpload,
1413
+ wait,
1414
+ local,
1415
+ state,
1416
+ repository,
1417
+ builds,
1418
+ submissions,
1419
+ progress,
1420
+ progressPrefix: platformName(target),
1421
+ skipBundleBuild: wait && !local,
1422
+ onEvent: (message) => progress.event(`${platformName(target)} · ${message}`),
1423
+ onProgress: (value, label) => {
1424
+ if (value !== undefined)
1425
+ progressValues[target] = value;
1426
+ if (label)
1427
+ progressLabels[target] = label;
1428
+ const average = (progressValues.android + progressValues.ios) / platforms.length;
1429
+ progress.update(average, `${platformName(target)} · ${progressLabels[target]}`);
1430
+ },
1431
+ }, false)));
1432
+ progress.update(100, "Android and iOS builds finished");
1433
+ progress.stop();
1434
+ const summaryBuilds = outcomes.map((outcome, index) => {
1435
+ if (outcome.status === "fulfilled")
1436
+ return outcome.value;
1437
+ const error = outcome.reason;
1438
+ const buildId = error && typeof error === "object" && "buildId" in error
1439
+ ? String(error.buildId)
1440
+ : undefined;
1441
+ const job = buildId ? builds.jobs.get(buildId) : undefined;
1442
+ return (job ?? {
1443
+ platform: platforms[index] ?? "android",
1444
+ state: "failed",
1445
+ error: error instanceof Error ? error.message : String(error),
1446
+ });
1447
+ });
1448
+ const failed = summaryBuilds.some((result) => result.state === "failed");
1449
+ printValue({
1450
+ status: failed
1451
+ ? "failed"
1452
+ : summaryBuilds.every((result) => result.state === "success")
1453
+ ? "success"
1454
+ : "queued",
1455
+ builds: summaryBuilds,
1456
+ }, {
1457
+ title: "Build all result",
1458
+ rows: summaryBuilds.map((result) => ({
1459
+ label: platformName(result.platform),
1460
+ value: `${result.state}${"id" in result ? ` · ${result.id}` : ""}`,
1461
+ valueColor: result.state === "success"
1462
+ ? "green"
1463
+ : result.state === "failed"
1464
+ ? "red"
1465
+ : "yellow",
1466
+ })),
1467
+ done: failed
1468
+ ? "At least one platform build failed. Review its events and retry that platform."
1469
+ : "Android and iOS build jobs completed. Submit each successful artifact separately.",
1228
1470
  });
1229
- if (result.state === "success" && result.artifact?.url)
1230
- ui.downloadArtifact(result.artifact.url, result.artifact.expiresAt);
1471
+ for (const result of summaryBuilds)
1472
+ if (result.state === "success" &&
1473
+ "artifact" in result &&
1474
+ result.artifact?.url)
1475
+ ui.downloadArtifact(result.artifact.url, result.artifact.expiresAt);
1476
+ if (failed)
1477
+ process.exitCode = 5;
1231
1478
  }
1232
1479
  function exitCode(error) {
1233
1480
  const code = error.code;
@@ -1248,14 +1495,23 @@ function exitCode(error) {
1248
1495
  void main()
1249
1496
  .catch((error) => {
1250
1497
  const message = error instanceof Error ? error.message : "Unknown error";
1498
+ const code = error.code ?? "CLI_ERROR";
1499
+ const nextSteps = guidanceForError(error);
1251
1500
  if (json) {
1252
1501
  console.log(JSON.stringify({
1253
1502
  error: message,
1254
- code: error.code ?? "CLI_ERROR",
1503
+ code,
1504
+ ...(nextSteps.commands.length > 0
1505
+ ? {
1506
+ nextSteps: nextSteps.commands,
1507
+ ...(nextSteps.note ? { note: nextSteps.note } : {}),
1508
+ }
1509
+ : {}),
1255
1510
  }));
1256
1511
  }
1257
1512
  else {
1258
1513
  ui.error(message);
1514
+ ui.nextSteps(nextSteps);
1259
1515
  }
1260
1516
  process.exitCode = exitCode(error);
1261
1517
  })
@@ -4,6 +4,7 @@ interface IosBuildOptions {
4
4
  root: string;
5
5
  profile: BuildProfile;
6
6
  uploadArtifacts?: boolean;
7
+ skipBundleBuild?: boolean;
7
8
  quiet?: boolean;
8
9
  onEvent?: (message: string) => void;
9
10
  onProgress?: (value?: number, label?: string) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"ios-build.d.ts","sourceRoot":"","sources":["../src/ios-build.ts"],"names":[],"mappings":"AAGA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAEpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAShD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,YAAY,CAAC;IACtB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,UAAU,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CACvD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAcxE;AA0BD,wBAAsB,eAAe,CACnC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,QAAQ,CAAC,CAmMnB"}
1
+ {"version":3,"file":"ios-build.d.ts","sourceRoot":"","sources":["../src/ios-build.ts"],"names":[],"mappings":"AAGA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAEpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAMhD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,YAAY,CAAC;IACtB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,UAAU,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CACvD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAcxE;AAsDD,wBAAsB,eAAe,CACnC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,QAAQ,CAAC,CAuMnB"}