@lynxship/cli 0.1.7 → 0.1.10

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 (53) hide show
  1. package/README.md +175 -19
  2. package/dist/android-toolchain.d.ts +23 -0
  3. package/dist/android-toolchain.d.ts.map +1 -0
  4. package/dist/android-toolchain.js +319 -0
  5. package/dist/artifact-build.d.ts +17 -0
  6. package/dist/artifact-build.d.ts.map +1 -0
  7. package/dist/artifact-build.js +56 -0
  8. package/dist/artifact-name.d.ts +1 -1
  9. package/dist/artifact-name.d.ts.map +1 -1
  10. package/dist/autolink.d.ts +3 -2
  11. package/dist/autolink.d.ts.map +1 -1
  12. package/dist/autolink.js +130 -6
  13. package/dist/bundle-build.d.ts +2 -0
  14. package/dist/bundle-build.d.ts.map +1 -1
  15. package/dist/bundle-build.js +11 -3
  16. package/dist/config.d.ts +19 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +1 -1
  19. package/dist/desktop-build.d.ts +22 -0
  20. package/dist/desktop-build.d.ts.map +1 -0
  21. package/dist/desktop-build.js +161 -0
  22. package/dist/desktop-signing.d.ts +14 -0
  23. package/dist/desktop-signing.d.ts.map +1 -0
  24. package/dist/desktop-signing.js +200 -0
  25. package/dist/guidance.d.ts.map +1 -1
  26. package/dist/guidance.js +113 -4
  27. package/dist/harmony-build.d.ts +22 -0
  28. package/dist/harmony-build.d.ts.map +1 -0
  29. package/dist/harmony-build.js +194 -0
  30. package/dist/index.js +333 -73
  31. package/dist/ios-toolchain.d.ts +19 -0
  32. package/dist/ios-toolchain.d.ts.map +1 -0
  33. package/dist/ios-toolchain.js +303 -0
  34. package/dist/lynx-devtool.d.ts +18 -0
  35. package/dist/lynx-devtool.d.ts.map +1 -0
  36. package/dist/lynx-devtool.js +125 -0
  37. package/dist/process-runner.d.ts +11 -0
  38. package/dist/process-runner.d.ts.map +1 -1
  39. package/dist/process-runner.js +29 -0
  40. package/dist/prompt.d.ts +1 -0
  41. package/dist/prompt.d.ts.map +1 -1
  42. package/dist/prompt.js +6 -0
  43. package/dist/remote.d.ts +3 -3
  44. package/dist/remote.d.ts.map +1 -1
  45. package/dist/target-toolchain.d.ts +17 -0
  46. package/dist/target-toolchain.d.ts.map +1 -0
  47. package/dist/target-toolchain.js +122 -0
  48. package/dist/ui/components.d.ts.map +1 -1
  49. package/dist/ui/components.js +8 -2
  50. package/dist/web-build.d.ts +16 -0
  51. package/dist/web-build.d.ts.map +1 -0
  52. package/dist/web-build.js +116 -0
  53. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { JsonRepository } from "@lynxship/db";
8
8
  import { assert, createId, sha256, } from "@lynxship/contracts";
9
9
  import { createSigningKey, signManifest, } from "@lynxship/signing";
10
10
  import { AppStoreConnectApiProvider, GooglePlayApiProvider, SubmissionService, } from "@lynxship/submit";
11
- import { DEFAULT_CONFIG, loadConfig, platformValue, } from "./config.js";
11
+ import { DEFAULT_CONFIG, loadConfig, platformValue, resolveProfile, } from "./config.js";
12
12
  import { hasAndroidHost, isSupportedAndroidPlatform, runRealAndroidBuild, } from "./android-build.js";
13
13
  import { initializeAndroidHost, suggestedAndroidApplicationId, } from "./android-host.js";
14
14
  import { initializeIosHost, suggestedIosBundleIdentifier } from "./ios-host.js";
@@ -23,10 +23,17 @@ import { inspectAutolink, requireAutolinkReady } from "./autolink.js";
23
23
  import { assertCompatibleBinaryBuild, inspectRuntimeFingerprint, } from "./runtime-fingerprint.js";
24
24
  import { inspectOtaHost } from "./ota-doctor.js";
25
25
  import { otaAssetName, otaAssetPaths } from "./ota-assets.js";
26
- import { prompt } from "./prompt.js";
26
+ import { confirm, prompt } from "./prompt.js";
27
27
  import { commandExists, packageManagerCommand, runProcess, runRspeedy, } from "./process-runner.js";
28
28
  import { guidanceForError } from "./guidance.js";
29
29
  import { buildLynxBundle } from "./bundle-build.js";
30
+ import { fixAndroidToolchain, formatAndroidToolchainFailure, inspectAndroidToolchain, } from "./android-toolchain.js";
31
+ import { formatIosToolchainFailure, inspectIosToolchain, } from "./ios-toolchain.js";
32
+ import { formatDevToolFailure, inspectLynxDevTool } from "./lynx-devtool.js";
33
+ import { hasHarmonyHost, runRealHarmonyBuild } from "./harmony-build.js";
34
+ import { hasWebConfiguration, runRealWebBuild } from "./web-build.js";
35
+ import { hasDesktopHost, runRealDesktopBuild } from "./desktop-build.js";
36
+ import { inspectDesktopTarget, inspectHarmonyTarget, inspectWebTarget, } from "./target-toolchain.js";
30
37
  const rawArgs = process.argv.slice(2);
31
38
  const args = [...rawArgs];
32
39
  const ui = createCliUi(rawArgs);
@@ -134,6 +141,11 @@ function configuredProjectId(config) {
134
141
  assert(typeof config.projectId === "string" && config.projectId.length > 0, "CLI_PROJECT_ID_REQUIRED", "lynxship.json must contain a generated projectId. Run `lynxship init` for a new project configuration.");
135
142
  return config.projectId;
136
143
  }
144
+ function mobilePlatformValue(value) {
145
+ const platform = platformValue(value);
146
+ assert(platform === "android" || platform === "ios", "PLATFORM_COMMAND_UNSUPPORTED", "This command supports only android or ios. Web, HarmonyOS and Desktop use their own build adapters.");
147
+ return platform;
148
+ }
137
149
  async function renderConfigurationFooter() {
138
150
  if (!ui.interactive || ui.options.quiet)
139
151
  return;
@@ -191,7 +203,10 @@ async function saveState(state, repository, builds, submissions) {
191
203
  let stateSaveQueue = Promise.resolve();
192
204
  async function executeBuild(options, showResult = true) {
193
205
  const { config, profile, platform, skipUpload, wait, local, state, repository, builds, submissions, progress: sharedProgress, progressPrefix, onProgress, onEvent, skipBundleBuild, } = options;
194
- await requireAutolinkReady(root, platform);
206
+ if (wait && !local)
207
+ await ensureNativeHostForBuild(platform, profile, config);
208
+ if (platform === "android" || platform === "ios")
209
+ await requireAutolinkReady(root, platform);
195
210
  const runtime = await inspectRuntimeFingerprint(root, platform, config);
196
211
  const job = await builds.create({
197
212
  projectId: configuredProjectId(config),
@@ -205,7 +220,7 @@ async function executeBuild(options, showResult = true) {
205
220
  ui.info(`Using profile: ${profile} · platform: ${platform}`);
206
221
  const ownsProgress = !sharedProgress;
207
222
  const progress = sharedProgress ??
208
- ui.progress(platform === "android" ? "Android build" : "iOS build");
223
+ ui.progress(`${platform[0].toUpperCase()}${platform.slice(1)} build`);
209
224
  const prefix = progressPrefix ? `${progressPrefix} · ` : "";
210
225
  const reportEvent = (message) => {
211
226
  if (onEvent)
@@ -227,12 +242,14 @@ async function executeBuild(options, showResult = true) {
227
242
  const realAndroid = platform === "android" &&
228
243
  isSupportedAndroidPlatform() &&
229
244
  (await hasAndroidHost(root));
230
- const realIos = platform === "ios" && hasIosHost(root, config.build?.[profile]);
245
+ const realIos = platform === "ios" && hasIosHost(root, resolveProfile(config, profile));
231
246
  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.");
247
+ assert(false, "IOS_HOST_REQUIRED", "A macOS Xcode host is required for a real iOS build. Run `lynxship ios host init --bundle-identifier <id>` or pass --bundle-identifier <id> to build.");
233
248
  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.");
249
+ assert(false, "ANDROID_HOST_REQUIRED", "This project has no Android Gradle host. Run `lynxship android host init --application-id <id>` or pass --application-id <id> to build. `lynxship dev` remains available for Lynx Explorer; `--local` does not create an APK.");
235
250
  if (realAndroid) {
251
+ const toolchain = await inspectAndroidToolchain(root);
252
+ assert(toolchain.ok, "ANDROID_TOOLCHAIN_REQUIRED", `Android toolchain is not ready: ${formatAndroidToolchainFailure(toolchain)}`);
236
253
  await runRealAndroidBuild(job, {
237
254
  root,
238
255
  profile: config.build?.[profile] ?? {},
@@ -244,6 +261,8 @@ async function executeBuild(options, showResult = true) {
244
261
  });
245
262
  }
246
263
  else if (realIos) {
264
+ const toolchain = await inspectIosToolchain(root, resolveProfile(config, profile));
265
+ assert(toolchain.ok, "IOS_TOOLCHAIN_REQUIRED", `iOS toolchain is not ready: ${formatIosToolchainFailure(toolchain)}`);
247
266
  await runRealIosBuild(job, {
248
267
  root,
249
268
  profile: config.build?.[profile] ?? {},
@@ -254,6 +273,40 @@ async function executeBuild(options, showResult = true) {
254
273
  onProgress: reportProgress,
255
274
  });
256
275
  }
276
+ else if (!local && platform === "web") {
277
+ await runRealWebBuild(job, {
278
+ root,
279
+ profile: resolveProfile(config, profile),
280
+ uploadArtifacts: !skipUpload,
281
+ skipBundleBuild,
282
+ quiet: json,
283
+ onEvent: reportEvent,
284
+ onProgress: reportProgress,
285
+ });
286
+ }
287
+ else if (!local && platform === "harmony") {
288
+ await runRealHarmonyBuild(job, {
289
+ root,
290
+ profile: resolveProfile(config, profile),
291
+ uploadArtifacts: !skipUpload,
292
+ skipBundleBuild,
293
+ quiet: json,
294
+ onEvent: reportEvent,
295
+ onProgress: reportProgress,
296
+ });
297
+ }
298
+ else if (!local && platform === "desktop") {
299
+ await runRealDesktopBuild(job, {
300
+ root,
301
+ profile: resolveProfile(config, profile),
302
+ uploadArtifacts: !skipUpload,
303
+ allowUnsigned: options.allowUnsigned,
304
+ skipBundleBuild,
305
+ quiet: json,
306
+ onEvent: reportEvent,
307
+ onProgress: reportProgress,
308
+ });
309
+ }
257
310
  else {
258
311
  await builds.run(job.id);
259
312
  }
@@ -275,7 +328,7 @@ async function executeBuild(options, showResult = true) {
275
328
  const result = builds.get(job.id);
276
329
  if (showResult) {
277
330
  printValue(result, {
278
- title: `${platform === "android" ? "Android" : "iOS"} build result`,
331
+ title: `${platform[0].toUpperCase()}${platform.slice(1)} build result`,
279
332
  rows: [
280
333
  { label: "Build ID", value: result.id, valueColor: "purple" },
281
334
  { label: "Platform", value: result.platform, valueColor: "blue" },
@@ -298,12 +351,12 @@ async function executeBuild(options, showResult = true) {
298
351
  async function buildSharedLynxBundle() {
299
352
  const progress = ui.progress("Shared Lynx bundle");
300
353
  try {
301
- progress.update(undefined, "Building Lynx bundle once for Android and iOS…");
354
+ progress.update(undefined, "Building the shared Lynx bundle once for native targets…");
302
355
  await buildLynxBundle(root, {
303
356
  quiet: json,
304
357
  onOutput: (message) => progress.event(message),
305
358
  });
306
- progress.update(100, "Shared Lynx bundle ready");
359
+ progress.update(100, "Shared native Lynx bundle ready");
307
360
  }
308
361
  finally {
309
362
  progress.stop();
@@ -344,6 +397,9 @@ function commandTitle(command) {
344
397
  storage: "Cloudflare R2 setup",
345
398
  android: "Android signing setup",
346
399
  store: "App store submission setup",
400
+ devtool: "Lynx DevTool diagnostics",
401
+ trace: "Lynx Trace diagnostics",
402
+ recorder: "Lynx Recorder diagnostics",
347
403
  }[command] ?? command);
348
404
  }
349
405
  function helpText() {
@@ -356,17 +412,20 @@ Commands:
356
412
  preview Preview the production Lynx bundle locally
357
413
  inspect Inspect Rspeedy/Rspack configuration
358
414
  profile Build with Rspack profiling enabled
415
+ devtool doctor Check Lynx DevTool integration and dev runtime
416
+ trace doctor Check Lynx Trace prerequisites
417
+ recorder doctor Check Lynx Recorder prerequisites
359
418
  autolink check Check Lynx native-library Autolink wiring
360
419
  autolink codegen Run the project's Native Module codegen script
361
420
  ota doctor Check native OTA host integration
362
- run Install an artifact on an Android/iOS target
363
- logs Stream Android/iOS native logs
421
+ run Install an artifact on an Android/iOS/HarmonyOS target
422
+ logs Stream Android/iOS/HarmonyOS native logs
364
423
  build create Create a local/cloud build job
365
424
  build list List build jobs
366
425
  build status <id> Show one build job
367
426
  build cancel <id> Cancel a build job
368
427
  build retry <id> Retry a failed build job
369
- build all Build Android and iOS on macOS
428
+ build all Build Android, iOS, HarmonyOS, Web and Desktop
370
429
  submit Submit the latest successful build
371
430
  update Upload and publish a signed OTA update
372
431
  update rollback Roll back an OTA channel to a previous release
@@ -379,10 +438,11 @@ Commands:
379
438
  store configure Configure Google Play or App Store Connect submission
380
439
 
381
440
  Build options:
382
- --platform <p> Target android, ios or all for build (default: android)
441
+ --platform <p> Target android, ios, harmony, web, desktop or all (default: android)
383
442
  --profile <name> Build profile (default: production)
384
443
  --no-wait Queue the build without executing it locally
385
- --no-upload Keep the signed artifact local and skip R2 (CI verification)
444
+ --no-upload Keep the artifact local and skip R2 (CI verification)
445
+ --allow-unsigned Allow an unsigned Desktop artifact only with --no-upload (local tests)
386
446
  --local Use the contract-only build path for tests
387
447
 
388
448
  Submit options:
@@ -405,12 +465,13 @@ Rollback options:
405
465
 
406
466
  Device and diagnostics options:
407
467
  doctor --platform <p> Check the local toolchain (default: android)
468
+ doctor --fix Install missing Android SDK packages after confirmation
408
469
  autolink check --platform <p>
409
470
  Check native-library wiring (default: android)
410
471
  ota doctor --platform <p>
411
472
  Check OTA host integration (default: android)
412
- run --artifact <path> Install a specific APK, IPA or app artifact
413
- run --device <id> Select the Android device or iOS simulator
473
+ run --artifact <path> Install a specific APK, IPA, app or signed HAP
474
+ run --device <id> Select an Android, iOS or HarmonyOS device
414
475
  run --simulator Install an iOS .app with simctl
415
476
  logs --device <id> Select the device or simulator for native logs
416
477
 
@@ -469,6 +530,56 @@ async function initializeBuildProject() {
469
530
  await initializeProject();
470
531
  ui.success("Created lynxship.json");
471
532
  }
533
+ async function ensureNativeHostForBuild(platform, profile, config) {
534
+ if (platform === "android") {
535
+ assert(isSupportedAndroidPlatform(), "ANDROID_PLATFORM_UNSUPPORTED", "Android builds are supported on Windows, macOS, and Linux.");
536
+ if (await hasAndroidHost(root))
537
+ return;
538
+ assert(!(await exists(join(root, "android"))), "ANDROID_HOST_EXISTS", "An android/ directory exists but does not contain a usable Gradle host. LynxShip will not overwrite it; repair it or remove it deliberately, then rerun the build.");
539
+ const suggestedId = suggestedAndroidApplicationId(root);
540
+ const applicationId = flag("--application-id") ??
541
+ (ui.interactive
542
+ ? await prompt("Android application ID", suggestedId)
543
+ : "");
544
+ assert(applicationId, "ANDROID_HOST_REQUIRED", `No Android Gradle host exists. Run \`lynxship android host init --application-id ${suggestedId}\` or pass --application-id <id> to build in non-interactive mode.`);
545
+ ui.info("No Android host found. Creating the official LynxShip host before the build…");
546
+ const result = await initializeAndroidHost(root, {
547
+ applicationId,
548
+ appName: basename(root),
549
+ });
550
+ ui.success(`Android host created: ${result.directory}`);
551
+ return;
552
+ }
553
+ if (platform === "web") {
554
+ const resolvedProfile = resolveProfile(config, profile);
555
+ assert(hasWebConfiguration(root) || Boolean(resolvedProfile.web?.script), "WEB_CONFIGURATION_REQUIRED", "No Lynx Web configuration was detected. Add environments.web to lynx.config.* or configure build.<profile>.web.script.");
556
+ return;
557
+ }
558
+ if (platform === "harmony") {
559
+ assert(hasHarmonyHost(root), "HARMONY_HOST_REQUIRED", "No complete HarmonyOS host was found. Add an official Lynx Harmony host under harmony/ before building.");
560
+ return;
561
+ }
562
+ if (platform === "desktop") {
563
+ const resolvedProfile = resolveProfile(config, profile);
564
+ assert(await hasDesktopHost(root, resolvedProfile), "DESKTOP_HOST_REQUIRED", "No Lynxtron desktop host was found. Use the official Lynxtron template or configure a pack script before building.");
565
+ return;
566
+ }
567
+ assert(process.platform === "darwin", "IOS_MACOS_REQUIRED", "iOS hosts and IPA builds require macOS with Xcode. Run this build on macOS or a macOS CI worker.");
568
+ const resolvedProfile = resolveProfile(config, profile);
569
+ if (hasIosHost(root, resolvedProfile))
570
+ return;
571
+ assert(!(await exists(join(root, "ios"))), "IOS_HOST_EXISTS", "An ios/ directory exists but does not contain a usable Xcode host. LynxShip will not overwrite it; repair it deliberately, then rerun the build.");
572
+ const suggestedId = suggestedIosBundleIdentifier(root);
573
+ const bundleIdentifier = flag("--bundle-identifier") ??
574
+ (ui.interactive ? await prompt("iOS bundle identifier", suggestedId) : "");
575
+ assert(bundleIdentifier, "IOS_HOST_REQUIRED", `No iOS host exists. Run \`lynxship ios host init --bundle-identifier ${suggestedId}\` or pass --bundle-identifier <id> to build in non-interactive mode.`);
576
+ ui.info("No iOS host found. Creating the official LynxShip host before the build…");
577
+ const result = await initializeIosHost(root, {
578
+ bundleIdentifier,
579
+ appName: basename(root),
580
+ });
581
+ ui.success(`iOS host created: ${result.directory}`);
582
+ }
472
583
  function forwardedToolArgs(values) {
473
584
  const result = [];
474
585
  const valueFlags = new Set([
@@ -541,6 +652,33 @@ async function runAutolinkCodegen() {
541
652
  done: "Native Module specifications were generated successfully.",
542
653
  });
543
654
  }
655
+ async function runDevToolDoctor(kind) {
656
+ await initializeBuildProject();
657
+ const platform = mobilePlatformValue(flag("--platform", "android"));
658
+ const status = await inspectLynxDevTool(root, platform);
659
+ const ready = kind === "devtool"
660
+ ? status.ok
661
+ : kind === "trace"
662
+ ? status.traceReady
663
+ : status.recorderReady;
664
+ if (!ready)
665
+ process.exitCode = 1;
666
+ printValue({ ...status, requested: kind, ready }, {
667
+ title: `Lynx ${kind === "devtool" ? "DevTool" : kind[0].toUpperCase() + kind.slice(1)} · ${platform}`,
668
+ rows: status.checks.map((item) => ({
669
+ label: item.name,
670
+ value: `${item.status} · ${item.value}${item.fix && item.status !== "pass" ? ` · fix: ${item.fix}` : ""}`,
671
+ valueColor: item.status === "pass"
672
+ ? "green"
673
+ : item.status === "warn"
674
+ ? "yellow"
675
+ : "red",
676
+ })),
677
+ done: ready
678
+ ? `${kind} prerequisites are ready. Open Lynx DevTool Desktop and connect the target device by USB.`
679
+ : `Lynx ${kind} prerequisites are incomplete: ${formatDevToolFailure(status) || "review the failed checks"}`,
680
+ });
681
+ }
544
682
  async function runDevice() {
545
683
  const platform = platformValue(flag("--platform", "android"));
546
684
  const artifact = flag("--artifact");
@@ -563,7 +701,7 @@ async function runDevice() {
563
701
  onOutput: (line) => ui.info(`│ ${line}`),
564
702
  });
565
703
  }
566
- else {
704
+ else if (platform === "ios") {
567
705
  assert(process.platform === "darwin", "IOS_MACOS_REQUIRED", "iOS device/simulator commands require macOS");
568
706
  assert(commandExists("xcrun"), "IOS_XCRUN_REQUIRED", "xcrun was not found in PATH");
569
707
  const device = flag("--device", "booted");
@@ -592,6 +730,23 @@ async function runDevice() {
592
730
  });
593
731
  }
594
732
  }
733
+ else if (platform === "harmony") {
734
+ assert(commandExists("hdc"), "HARMONY_HDC_REQUIRED", "hdc was not found in PATH. Install the OpenHarmony SDK platform tools before installing a HAP.");
735
+ assert(artifactPath.endsWith(".hap"), "DEVICE_ARTIFACT_INVALID", "HarmonyOS run requires a signed .hap artifact.");
736
+ const device = flag("--device");
737
+ await runProcess("hdc", device
738
+ ? ["-t", device, "install", "-r", artifactPath]
739
+ : ["install", "-r", artifactPath], {
740
+ cwd: root,
741
+ quiet: json,
742
+ onOutput: (line) => ui.info(`│ ${line}`),
743
+ });
744
+ }
745
+ else {
746
+ assert(false, "TARGET_RUN_UNSUPPORTED", platform === "web"
747
+ ? "Web artifacts are previewed with `lynxship preview` or served by the project; they are not installed on a device."
748
+ : "Desktop installers are launched by the operating system after packaging; LynxShip does not claim a cross-platform install command.");
749
+ }
595
750
  printValue({ status: "installed", platform, artifact: artifactPath }, {
596
751
  title: "Device install",
597
752
  rows: [{ label: "Artifact", value: artifactPath, valueColor: "green" }],
@@ -600,7 +755,7 @@ async function runDevice() {
600
755
  }
601
756
  async function streamNativeLogs() {
602
757
  const platform = platformValue(flag("--platform", "android"));
603
- const device = flag("--device", platform === "android" ? undefined : "booted");
758
+ const device = flag("--device", platform === "ios" ? "booted" : undefined);
604
759
  if (platform === "android") {
605
760
  assert(commandExists("adb"), "ANDROID_ADB_REQUIRED", "adb was not found in PATH");
606
761
  await runProcess("adb", device ? ["-s", device, "logcat"] : ["logcat"], {
@@ -609,7 +764,7 @@ async function streamNativeLogs() {
609
764
  onOutput: (line) => ui.info(`│ ${line}`),
610
765
  });
611
766
  }
612
- else {
767
+ else if (platform === "ios") {
613
768
  assert(process.platform === "darwin", "IOS_MACOS_REQUIRED", "iOS logs require macOS");
614
769
  assert(commandExists("xcrun"), "IOS_XCRUN_REQUIRED", "xcrun was not found in PATH");
615
770
  assert(device !== "booted", "IOS_DEVICE_LOGS_UNSUPPORTED", "Use --device <simulator-identifier> for iOS simulator logs.");
@@ -625,6 +780,22 @@ async function streamNativeLogs() {
625
780
  "debug",
626
781
  ], { cwd: root, quiet: json, onOutput: (line) => ui.info(`│ ${line}`) });
627
782
  }
783
+ else if (platform === "harmony") {
784
+ assert(commandExists("hdc"), "HARMONY_HDC_REQUIRED", "hdc was not found in PATH. Install the OpenHarmony SDK platform tools before streaming logs.");
785
+ const argsForHdc = device
786
+ ? ["-t", device, "shell", "hilog"]
787
+ : ["shell", "hilog"];
788
+ await runProcess("hdc", argsForHdc, {
789
+ cwd: root,
790
+ quiet: json,
791
+ onOutput: (line) => ui.info(`│ ${line}`),
792
+ });
793
+ }
794
+ else {
795
+ assert(false, "TARGET_LOGS_UNSUPPORTED", platform === "web"
796
+ ? "Web logs are emitted by the browser/runtime console; use lynxship dev or the project's Web DevTools."
797
+ : "Desktop logs are emitted by the packaged runtime; use the target OS logging tools.");
798
+ }
628
799
  }
629
800
  async function main() {
630
801
  const command = args.shift() ?? "help";
@@ -678,12 +849,42 @@ async function main() {
678
849
  const config = await loadConfig(root);
679
850
  const configuration = await readConfigurationStatus();
680
851
  const doctorPlatform = platformValue(flag("--platform", "android"));
681
- const autolink = await inspectAutolink(root);
682
- const autolinkForPlatform = autolink[doctorPlatform];
852
+ const autolink = doctorPlatform === "android" || doctorPlatform === "ios"
853
+ ? await inspectAutolink(root)
854
+ : undefined;
855
+ const autolinkForPlatform = autolink && (doctorPlatform === "android" || doctorPlatform === "ios")
856
+ ? autolink[doctorPlatform]
857
+ : undefined;
683
858
  const lockfile = await findLockfile(root);
684
859
  const credentialStore = credentialStorageDescription();
685
860
  const nativeCredentialStore = !credentialStore.includes("owner-only");
686
861
  const androidHost = doctorPlatform === "android" ? await hasAndroidHost(root) : false;
862
+ const doctorProfileName = flag("--profile", "production");
863
+ const doctorProfile = config.build?.[doctorProfileName]
864
+ ? resolveProfile(config, doctorProfileName)
865
+ : {
866
+ name: doctorProfileName,
867
+ ...(DEFAULT_CONFIG.build?.production ?? {}),
868
+ };
869
+ const targetToolchain = doctorPlatform === "web"
870
+ ? await inspectWebTarget(root, doctorProfile)
871
+ : doctorPlatform === "harmony"
872
+ ? await inspectHarmonyTarget(root, doctorProfile)
873
+ : doctorPlatform === "desktop"
874
+ ? await inspectDesktopTarget(root, doctorProfile)
875
+ : undefined;
876
+ assert(!args.includes("--fix") || doctorPlatform === "android", "CLI_DOCTOR_FIX_PLATFORM", "`doctor --fix` currently repairs Android SDK packages; run it with `--platform android`.");
877
+ assert(!args.includes("--fix") || ui.interactive, "CLI_INTERACTIVE_REQUIRED", "Run `lynxship doctor --platform android --fix` in an interactive terminal.");
878
+ let androidToolchain = doctorPlatform === "android"
879
+ ? await inspectAndroidToolchain(root)
880
+ : undefined;
881
+ const iosToolchain = doctorPlatform === "ios"
882
+ ? await inspectIosToolchain(root, doctorProfile)
883
+ : undefined;
884
+ if (androidToolchain && args.includes("--fix")) {
885
+ await fixAndroidToolchain(root, androidToolchain, (message) => confirm(message), (line) => ui.info(line));
886
+ androidToolchain = await inspectAndroidToolchain(root);
887
+ }
687
888
  const nodeMajor = Number(process.versions.node.split(".")[0]);
688
889
  const nodeSupported = nodeMajor >= 22;
689
890
  const nodeRecommended = nodeMajor % 2 === 0;
@@ -732,8 +933,34 @@ async function main() {
732
933
  ? "Gradle host found"
733
934
  : "missing · fix: lynxship android host init --application-id com.example.myapp",
734
935
  },
936
+ ...(androidToolchain?.checks ?? []).map((toolchainCheck) => ({
937
+ name: toolchainCheck.name,
938
+ ok: toolchainCheck.ok,
939
+ status: toolchainCheck.status,
940
+ value: toolchainCheck.fix && toolchainCheck.status !== "pass"
941
+ ? `${toolchainCheck.value} · fix: ${toolchainCheck.fix}`
942
+ : toolchainCheck.value,
943
+ })),
735
944
  ]
736
- : []),
945
+ : doctorPlatform === "ios"
946
+ ? (iosToolchain?.checks ?? [])
947
+ .filter((toolchainCheck) => toolchainCheck.name !== "ios-host")
948
+ .map((toolchainCheck) => ({
949
+ name: toolchainCheck.name,
950
+ ok: toolchainCheck.ok,
951
+ status: toolchainCheck.status,
952
+ value: toolchainCheck.fix && toolchainCheck.status !== "pass"
953
+ ? `${toolchainCheck.value} · fix: ${toolchainCheck.fix}`
954
+ : toolchainCheck.value,
955
+ }))
956
+ : (targetToolchain?.checks ?? []).map((toolchainCheck) => ({
957
+ name: toolchainCheck.name,
958
+ ok: toolchainCheck.ok,
959
+ status: toolchainCheck.status,
960
+ value: toolchainCheck.fix && toolchainCheck.status !== "pass"
961
+ ? `${toolchainCheck.value} · fix: ${toolchainCheck.fix}`
962
+ : toolchainCheck.value,
963
+ }))),
737
964
  {
738
965
  name: "cloudflare-r2",
739
966
  ok: configuration.r2,
@@ -742,32 +969,47 @@ async function main() {
742
969
  ? "configured"
743
970
  : "missing · fix: lynxship storage configure",
744
971
  },
745
- {
746
- name: doctorPlatform === "android" ? "android-signing" : "ios-host",
747
- ok: doctorPlatform === "android"
748
- ? configuration.android
749
- : process.platform === "darwin" && hasIosHost(root),
750
- status: (doctorPlatform === "android"
751
- ? configuration.android
752
- : process.platform === "darwin" && hasIosHost(root))
753
- ? "pass"
754
- : "fail",
755
- value: doctorPlatform === "android"
756
- ? configuration.android
757
- ? "configured"
758
- : "missing · fix: lynxship android configure"
759
- : process.platform === "darwin" && hasIosHost(root)
760
- ? "Xcode host found"
761
- : "missing · fix: use macOS, then lynxship ios host init --bundle-identifier com.example.myapp",
762
- },
763
- {
764
- name: `lynx-autolink-${doctorPlatform}`,
765
- ok: autolinkForPlatform.ready,
766
- status: autolinkForPlatform.ready ? "pass" : "fail",
767
- value: autolinkForPlatform.ready
768
- ? autolinkForPlatform.reason
769
- : `${autolinkForPlatform.reason} · fix: install the native plugin, then run autolink codegen`,
770
- },
972
+ ...(doctorPlatform === "android"
973
+ ? [
974
+ {
975
+ name: "android-signing",
976
+ ok: configuration.android,
977
+ status: configuration.android
978
+ ? "pass"
979
+ : "fail",
980
+ value: configuration.android
981
+ ? "configured"
982
+ : "missing · fix: lynxship android configure",
983
+ },
984
+ ]
985
+ : doctorPlatform === "ios"
986
+ ? [
987
+ {
988
+ name: "ios-host",
989
+ ok: process.platform === "darwin" && hasIosHost(root),
990
+ status: process.platform === "darwin" && hasIosHost(root)
991
+ ? "pass"
992
+ : "fail",
993
+ value: process.platform === "darwin" && hasIosHost(root)
994
+ ? "Xcode host found"
995
+ : "missing · fix: use macOS, then lynxship ios host init --bundle-identifier com.example.myapp",
996
+ },
997
+ ]
998
+ : []),
999
+ ...(autolinkForPlatform
1000
+ ? [
1001
+ {
1002
+ name: `lynx-autolink-${doctorPlatform}`,
1003
+ ok: autolinkForPlatform.ready,
1004
+ status: autolinkForPlatform.ready
1005
+ ? "pass"
1006
+ : "fail",
1007
+ value: autolinkForPlatform.ready
1008
+ ? autolinkForPlatform.reason
1009
+ : `${autolinkForPlatform.reason} · fix: install the native plugin, then run autolink codegen`,
1010
+ },
1011
+ ]
1012
+ : []),
771
1013
  ];
772
1014
  const result = {
773
1015
  ok: checks.every((check) => check.status !== "fail"),
@@ -778,6 +1020,8 @@ async function main() {
778
1020
  ui.warn("One or more environment checks failed");
779
1021
  else if (hasWarnings)
780
1022
  ui.warn("Environment is usable, but one recommendation needs attention");
1023
+ if (!result.ok)
1024
+ process.exitCode = 1;
781
1025
  printValue(result, {
782
1026
  title: "Doctor result",
783
1027
  rows: checks.map((check) => ({
@@ -808,6 +1052,12 @@ async function main() {
808
1052
  });
809
1053
  return;
810
1054
  }
1055
+ if (["devtool", "trace", "recorder"].includes(command)) {
1056
+ const subcommand = args.shift() ?? "doctor";
1057
+ assert(subcommand === "doctor", "CLI_DEVTOOL_COMMAND", "Use `lynxship devtool doctor`, `lynxship trace doctor` or `lynxship recorder doctor`.");
1058
+ await runDevToolDoctor(command);
1059
+ return;
1060
+ }
811
1061
  if (command === "autolink") {
812
1062
  const subcommand = args.shift() ?? "check";
813
1063
  assert(["check", "codegen"].includes(subcommand), "CLI_AUTOLINK_COMMAND", "Use `lynxship autolink check` or `lynxship autolink codegen`");
@@ -815,7 +1065,7 @@ async function main() {
815
1065
  await runAutolinkCodegen();
816
1066
  return;
817
1067
  }
818
- const platform = platformValue(flag("--platform", "android"));
1068
+ const platform = mobilePlatformValue(flag("--platform", "android"));
819
1069
  const status = (await inspectAutolink(root))[platform];
820
1070
  printValue(status, {
821
1071
  title: `Lynx Autolink · ${platform}`,
@@ -844,7 +1094,7 @@ async function main() {
844
1094
  }
845
1095
  if (command === "ota") {
846
1096
  assert((args.shift() ?? "doctor") === "doctor", "CLI_OTA_COMMAND", "Only `lynxship ota doctor` is available");
847
- const platform = platformValue(flag("--platform", "android"));
1097
+ const platform = mobilePlatformValue(flag("--platform", "android"));
848
1098
  const status = await inspectOtaHost(root, platform);
849
1099
  printValue(status, {
850
1100
  title: `OTA host · ${platform}`,
@@ -1026,7 +1276,7 @@ async function main() {
1026
1276
  if (command === "store") {
1027
1277
  assert(ui.interactive, "CLI_INTERACTIVE_REQUIRED", "Run store configure in an interactive terminal");
1028
1278
  assert((args.shift() ?? "configure") === "configure", "CLI_STORE_COMMAND", "Only store configure is available");
1029
- const platform = platformValue(flag("--platform", "android"));
1279
+ const platform = mobilePlatformValue(flag("--platform", "android"));
1030
1280
  ui.info(platform === "android"
1031
1281
  ? "Configuring Google Play submission. Secret fields will stay invisible…"
1032
1282
  : "Configuring App Store Connect submission. Private key contents stay protected…");
@@ -1066,7 +1316,7 @@ async function main() {
1066
1316
  const { state, repository, builds, submissions } = await loadState();
1067
1317
  if (command === "submit") {
1068
1318
  const config = await loadConfig(root);
1069
- const platform = platformValue(flag("--platform", "android"));
1319
+ const platform = mobilePlatformValue(flag("--platform", "android"));
1070
1320
  await requireOperationalConfiguration(platform);
1071
1321
  const credentials = await loadCredentials(root);
1072
1322
  const localMode = args.includes("--local") || process.env.LYNXSHIP_SUBMIT_MODE === "mock";
@@ -1151,7 +1401,7 @@ async function main() {
1151
1401
  }
1152
1402
  if (command === "update" && args[0] !== "rollback") {
1153
1403
  const config = await loadConfig(root);
1154
- const platform = platformValue(flag("--platform", "android"));
1404
+ const platform = mobilePlatformValue(flag("--platform", "android"));
1155
1405
  await requireOperationalConfiguration(platform);
1156
1406
  const projectId = configuredProjectId(config);
1157
1407
  const localMode = args.includes("--local") || process.env.LYNXSHIP_SUBMIT_MODE === "mock";
@@ -1273,7 +1523,7 @@ async function main() {
1273
1523
  if (command === "update")
1274
1524
  args.shift();
1275
1525
  const config = await loadConfig(root);
1276
- const platform = platformValue(flag("--platform", "android"));
1526
+ const platform = mobilePlatformValue(flag("--platform", "android"));
1277
1527
  const releaseId = flag("--release-id");
1278
1528
  const reason = flag("--reason");
1279
1529
  const channel = config.update?.channel ?? "production";
@@ -1345,6 +1595,8 @@ async function main() {
1345
1595
  const buildAll = subcommand === "all" || platformArgument === "all";
1346
1596
  const platform = buildAll ? "android" : platformValue(platformArgument);
1347
1597
  const skipUpload = args.includes("--no-upload");
1598
+ const allowUnsigned = args.includes("--allow-unsigned");
1599
+ assert(!allowUnsigned || skipUpload, "CLI_UNSIGNED_UPLOAD_BLOCKED", "--allow-unsigned is only available with --no-upload and can never upload an unsigned Desktop artifact.");
1348
1600
  await requireOperationalConfiguration(platform, { requireR2: !skipUpload });
1349
1601
  if (subcommand === "list") {
1350
1602
  printValue(builds.list());
@@ -1372,11 +1624,13 @@ async function main() {
1372
1624
  const profile = flag("--profile", "production");
1373
1625
  const wait = !args.includes("--no-wait");
1374
1626
  const local = args.includes("--local");
1375
- const platforms = buildAll ? ["android", "ios"] : [platform];
1627
+ const platforms = buildAll
1628
+ ? ["android", "ios", "harmony", "web", "desktop"]
1629
+ : [platform];
1376
1630
  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`.");
1631
+ assert(process.platform === "darwin", "BUILD_ALL_MACOS_REQUIRED", "A real all-target build includes iOS and therefore requires macOS locally. Run supported targets individually on Windows/Linux, or use a macOS CI worker for the complete matrix.");
1632
+ for (const target of platforms)
1633
+ await ensureNativeHostForBuild(target, profile, config);
1380
1634
  }
1381
1635
  if (!buildAll) {
1382
1636
  await executeBuild({
@@ -1384,6 +1638,7 @@ async function main() {
1384
1638
  profile,
1385
1639
  platform,
1386
1640
  skipUpload,
1641
+ allowUnsigned,
1387
1642
  wait,
1388
1643
  local,
1389
1644
  state,
@@ -1395,21 +1650,24 @@ async function main() {
1395
1650
  }
1396
1651
  if (buildAll && wait && !local)
1397
1652
  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";
1653
+ const progress = ui.progress("All Lynx targets build");
1654
+ const progressValues = {};
1655
+ const progressLabels = {};
1656
+ const platformName = (target) => target === "android"
1657
+ ? "Android"
1658
+ : target === "ios"
1659
+ ? "iOS"
1660
+ : target === "harmony"
1661
+ ? "HarmonyOS"
1662
+ : target === "web"
1663
+ ? "Web"
1664
+ : "Desktop";
1408
1665
  const outcomes = await Promise.allSettled(platforms.map((target) => executeBuild({
1409
1666
  config,
1410
1667
  profile,
1411
1668
  platform: target,
1412
1669
  skipUpload,
1670
+ allowUnsigned,
1413
1671
  wait,
1414
1672
  local,
1415
1673
  state,
@@ -1418,18 +1676,18 @@ async function main() {
1418
1676
  submissions,
1419
1677
  progress,
1420
1678
  progressPrefix: platformName(target),
1421
- skipBundleBuild: wait && !local,
1679
+ skipBundleBuild: wait && !local && target !== "web",
1422
1680
  onEvent: (message) => progress.event(`${platformName(target)} · ${message}`),
1423
1681
  onProgress: (value, label) => {
1424
1682
  if (value !== undefined)
1425
1683
  progressValues[target] = value;
1426
1684
  if (label)
1427
1685
  progressLabels[target] = label;
1428
- const average = (progressValues.android + progressValues.ios) / platforms.length;
1686
+ const average = platforms.reduce((total, current) => total + (progressValues[current] ?? 0), 0) / platforms.length;
1429
1687
  progress.update(average, `${platformName(target)} · ${progressLabels[target]}`);
1430
1688
  },
1431
1689
  }, false)));
1432
- progress.update(100, "Android and iOS builds finished");
1690
+ progress.update(100, "All Lynx target builds finished");
1433
1691
  progress.stop();
1434
1692
  const summaryBuilds = outcomes.map((outcome, index) => {
1435
1693
  if (outcome.status === "fulfilled")
@@ -1466,7 +1724,7 @@ async function main() {
1466
1724
  })),
1467
1725
  done: failed
1468
1726
  ? "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.",
1727
+ : "All Lynx target build jobs completed. Submit supported store artifacts separately.",
1470
1728
  });
1471
1729
  for (const result of summaryBuilds)
1472
1730
  if (result.state === "success" &&
@@ -1482,6 +1740,8 @@ function exitCode(error) {
1482
1740
  return 2;
1483
1741
  if (code === "BUILD_SIGNING_REQUIRED")
1484
1742
  return 2;
1743
+ if (code === "DESKTOP_SIGNING_REQUIRED")
1744
+ return 2;
1485
1745
  if (code?.startsWith("AUTH_"))
1486
1746
  return 4;
1487
1747
  if (code?.startsWith("BUILD_"))