@lynxship/cli 0.1.8 → 0.1.11

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 (60) 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/dev-qr.d.ts +3 -0
  26. package/dist/dev-qr.d.ts.map +1 -0
  27. package/dist/dev-qr.js +12 -0
  28. package/dist/guidance.d.ts.map +1 -1
  29. package/dist/guidance.js +113 -4
  30. package/dist/harmony-build.d.ts +22 -0
  31. package/dist/harmony-build.d.ts.map +1 -0
  32. package/dist/harmony-build.js +194 -0
  33. package/dist/index.js +359 -74
  34. package/dist/ios-toolchain.d.ts +19 -0
  35. package/dist/ios-toolchain.d.ts.map +1 -0
  36. package/dist/ios-toolchain.js +303 -0
  37. package/dist/lynx-devtool.d.ts +18 -0
  38. package/dist/lynx-devtool.d.ts.map +1 -0
  39. package/dist/lynx-devtool.js +125 -0
  40. package/dist/process-runner.d.ts +11 -0
  41. package/dist/process-runner.d.ts.map +1 -1
  42. package/dist/process-runner.js +29 -0
  43. package/dist/prompt.d.ts +1 -0
  44. package/dist/prompt.d.ts.map +1 -1
  45. package/dist/prompt.js +6 -0
  46. package/dist/remote.d.ts +3 -3
  47. package/dist/remote.d.ts.map +1 -1
  48. package/dist/target-toolchain.d.ts +17 -0
  49. package/dist/target-toolchain.d.ts.map +1 -0
  50. package/dist/target-toolchain.js +122 -0
  51. package/dist/ui/components.d.ts +1 -0
  52. package/dist/ui/components.d.ts.map +1 -1
  53. package/dist/ui/components.js +7 -0
  54. package/dist/ui/index.d.ts +1 -0
  55. package/dist/ui/index.d.ts.map +1 -1
  56. package/dist/ui/index.js +4 -1
  57. package/dist/web-build.d.ts +16 -0
  58. package/dist/web-build.d.ts.map +1 -0
  59. package/dist/web-build.js +116 -0
  60. package/package.json +3 -3
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,18 @@ 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 { extractDevServerUrl, shouldPrintDevServerQr } from "./dev-qr.js";
37
+ import { inspectDesktopTarget, inspectHarmonyTarget, inspectWebTarget, } from "./target-toolchain.js";
30
38
  const rawArgs = process.argv.slice(2);
31
39
  const args = [...rawArgs];
32
40
  const ui = createCliUi(rawArgs);
@@ -134,6 +142,11 @@ function configuredProjectId(config) {
134
142
  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
143
  return config.projectId;
136
144
  }
145
+ function mobilePlatformValue(value) {
146
+ const platform = platformValue(value);
147
+ assert(platform === "android" || platform === "ios", "PLATFORM_COMMAND_UNSUPPORTED", "This command supports only android or ios. Web, HarmonyOS and Desktop use their own build adapters.");
148
+ return platform;
149
+ }
137
150
  async function renderConfigurationFooter() {
138
151
  if (!ui.interactive || ui.options.quiet)
139
152
  return;
@@ -191,7 +204,10 @@ async function saveState(state, repository, builds, submissions) {
191
204
  let stateSaveQueue = Promise.resolve();
192
205
  async function executeBuild(options, showResult = true) {
193
206
  const { config, profile, platform, skipUpload, wait, local, state, repository, builds, submissions, progress: sharedProgress, progressPrefix, onProgress, onEvent, skipBundleBuild, } = options;
194
- await requireAutolinkReady(root, platform);
207
+ if (wait && !local)
208
+ await ensureNativeHostForBuild(platform, profile, config);
209
+ if (platform === "android" || platform === "ios")
210
+ await requireAutolinkReady(root, platform);
195
211
  const runtime = await inspectRuntimeFingerprint(root, platform, config);
196
212
  const job = await builds.create({
197
213
  projectId: configuredProjectId(config),
@@ -205,7 +221,7 @@ async function executeBuild(options, showResult = true) {
205
221
  ui.info(`Using profile: ${profile} · platform: ${platform}`);
206
222
  const ownsProgress = !sharedProgress;
207
223
  const progress = sharedProgress ??
208
- ui.progress(platform === "android" ? "Android build" : "iOS build");
224
+ ui.progress(`${platform[0].toUpperCase()}${platform.slice(1)} build`);
209
225
  const prefix = progressPrefix ? `${progressPrefix} · ` : "";
210
226
  const reportEvent = (message) => {
211
227
  if (onEvent)
@@ -227,12 +243,14 @@ async function executeBuild(options, showResult = true) {
227
243
  const realAndroid = platform === "android" &&
228
244
  isSupportedAndroidPlatform() &&
229
245
  (await hasAndroidHost(root));
230
- const realIos = platform === "ios" && hasIosHost(root, config.build?.[profile]);
246
+ const realIos = platform === "ios" && hasIosHost(root, resolveProfile(config, profile));
231
247
  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.");
248
+ 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
249
  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.");
250
+ 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
251
  if (realAndroid) {
252
+ const toolchain = await inspectAndroidToolchain(root);
253
+ assert(toolchain.ok, "ANDROID_TOOLCHAIN_REQUIRED", `Android toolchain is not ready: ${formatAndroidToolchainFailure(toolchain)}`);
236
254
  await runRealAndroidBuild(job, {
237
255
  root,
238
256
  profile: config.build?.[profile] ?? {},
@@ -244,6 +262,8 @@ async function executeBuild(options, showResult = true) {
244
262
  });
245
263
  }
246
264
  else if (realIos) {
265
+ const toolchain = await inspectIosToolchain(root, resolveProfile(config, profile));
266
+ assert(toolchain.ok, "IOS_TOOLCHAIN_REQUIRED", `iOS toolchain is not ready: ${formatIosToolchainFailure(toolchain)}`);
247
267
  await runRealIosBuild(job, {
248
268
  root,
249
269
  profile: config.build?.[profile] ?? {},
@@ -254,6 +274,40 @@ async function executeBuild(options, showResult = true) {
254
274
  onProgress: reportProgress,
255
275
  });
256
276
  }
277
+ else if (!local && platform === "web") {
278
+ await runRealWebBuild(job, {
279
+ root,
280
+ profile: resolveProfile(config, profile),
281
+ uploadArtifacts: !skipUpload,
282
+ skipBundleBuild,
283
+ quiet: json,
284
+ onEvent: reportEvent,
285
+ onProgress: reportProgress,
286
+ });
287
+ }
288
+ else if (!local && platform === "harmony") {
289
+ await runRealHarmonyBuild(job, {
290
+ root,
291
+ profile: resolveProfile(config, profile),
292
+ uploadArtifacts: !skipUpload,
293
+ skipBundleBuild,
294
+ quiet: json,
295
+ onEvent: reportEvent,
296
+ onProgress: reportProgress,
297
+ });
298
+ }
299
+ else if (!local && platform === "desktop") {
300
+ await runRealDesktopBuild(job, {
301
+ root,
302
+ profile: resolveProfile(config, profile),
303
+ uploadArtifacts: !skipUpload,
304
+ allowUnsigned: options.allowUnsigned,
305
+ skipBundleBuild,
306
+ quiet: json,
307
+ onEvent: reportEvent,
308
+ onProgress: reportProgress,
309
+ });
310
+ }
257
311
  else {
258
312
  await builds.run(job.id);
259
313
  }
@@ -275,7 +329,7 @@ async function executeBuild(options, showResult = true) {
275
329
  const result = builds.get(job.id);
276
330
  if (showResult) {
277
331
  printValue(result, {
278
- title: `${platform === "android" ? "Android" : "iOS"} build result`,
332
+ title: `${platform[0].toUpperCase()}${platform.slice(1)} build result`,
279
333
  rows: [
280
334
  { label: "Build ID", value: result.id, valueColor: "purple" },
281
335
  { label: "Platform", value: result.platform, valueColor: "blue" },
@@ -298,12 +352,12 @@ async function executeBuild(options, showResult = true) {
298
352
  async function buildSharedLynxBundle() {
299
353
  const progress = ui.progress("Shared Lynx bundle");
300
354
  try {
301
- progress.update(undefined, "Building Lynx bundle once for Android and iOS…");
355
+ progress.update(undefined, "Building the shared Lynx bundle once for native targets…");
302
356
  await buildLynxBundle(root, {
303
357
  quiet: json,
304
358
  onOutput: (message) => progress.event(message),
305
359
  });
306
- progress.update(100, "Shared Lynx bundle ready");
360
+ progress.update(100, "Shared native Lynx bundle ready");
307
361
  }
308
362
  finally {
309
363
  progress.stop();
@@ -344,6 +398,9 @@ function commandTitle(command) {
344
398
  storage: "Cloudflare R2 setup",
345
399
  android: "Android signing setup",
346
400
  store: "App store submission setup",
401
+ devtool: "Lynx DevTool diagnostics",
402
+ trace: "Lynx Trace diagnostics",
403
+ recorder: "Lynx Recorder diagnostics",
347
404
  }[command] ?? command);
348
405
  }
349
406
  function helpText() {
@@ -356,17 +413,20 @@ Commands:
356
413
  preview Preview the production Lynx bundle locally
357
414
  inspect Inspect Rspeedy/Rspack configuration
358
415
  profile Build with Rspack profiling enabled
416
+ devtool doctor Check Lynx DevTool integration and dev runtime
417
+ trace doctor Check Lynx Trace prerequisites
418
+ recorder doctor Check Lynx Recorder prerequisites
359
419
  autolink check Check Lynx native-library Autolink wiring
360
420
  autolink codegen Run the project's Native Module codegen script
361
421
  ota doctor Check native OTA host integration
362
- run Install an artifact on an Android/iOS target
363
- logs Stream Android/iOS native logs
422
+ run Install an artifact on an Android/iOS/HarmonyOS target
423
+ logs Stream Android/iOS/HarmonyOS native logs
364
424
  build create Create a local/cloud build job
365
425
  build list List build jobs
366
426
  build status <id> Show one build job
367
427
  build cancel <id> Cancel a build job
368
428
  build retry <id> Retry a failed build job
369
- build all Build Android and iOS on macOS
429
+ build all Build Android, iOS, HarmonyOS, Web and Desktop
370
430
  submit Submit the latest successful build
371
431
  update Upload and publish a signed OTA update
372
432
  update rollback Roll back an OTA channel to a previous release
@@ -379,10 +439,11 @@ Commands:
379
439
  store configure Configure Google Play or App Store Connect submission
380
440
 
381
441
  Build options:
382
- --platform <p> Target android, ios or all for build (default: android)
442
+ --platform <p> Target android, ios, harmony, web, desktop or all (default: android)
383
443
  --profile <name> Build profile (default: production)
384
444
  --no-wait Queue the build without executing it locally
385
- --no-upload Keep the signed artifact local and skip R2 (CI verification)
445
+ --no-upload Keep the artifact local and skip R2 (CI verification)
446
+ --allow-unsigned Allow an unsigned Desktop artifact only with --no-upload (local tests)
386
447
  --local Use the contract-only build path for tests
387
448
 
388
449
  Submit options:
@@ -405,12 +466,13 @@ Rollback options:
405
466
 
406
467
  Device and diagnostics options:
407
468
  doctor --platform <p> Check the local toolchain (default: android)
469
+ doctor --fix Install missing Android SDK packages after confirmation
408
470
  autolink check --platform <p>
409
471
  Check native-library wiring (default: android)
410
472
  ota doctor --platform <p>
411
473
  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
474
+ run --artifact <path> Install a specific APK, IPA, app or signed HAP
475
+ run --device <id> Select an Android, iOS or HarmonyOS device
414
476
  run --simulator Install an iOS .app with simctl
415
477
  logs --device <id> Select the device or simulator for native logs
416
478
 
@@ -469,6 +531,56 @@ async function initializeBuildProject() {
469
531
  await initializeProject();
470
532
  ui.success("Created lynxship.json");
471
533
  }
534
+ async function ensureNativeHostForBuild(platform, profile, config) {
535
+ if (platform === "android") {
536
+ assert(isSupportedAndroidPlatform(), "ANDROID_PLATFORM_UNSUPPORTED", "Android builds are supported on Windows, macOS, and Linux.");
537
+ if (await hasAndroidHost(root))
538
+ return;
539
+ 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.");
540
+ const suggestedId = suggestedAndroidApplicationId(root);
541
+ const applicationId = flag("--application-id") ??
542
+ (ui.interactive
543
+ ? await prompt("Android application ID", suggestedId)
544
+ : "");
545
+ 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.`);
546
+ ui.info("No Android host found. Creating the official LynxShip host before the build…");
547
+ const result = await initializeAndroidHost(root, {
548
+ applicationId,
549
+ appName: basename(root),
550
+ });
551
+ ui.success(`Android host created: ${result.directory}`);
552
+ return;
553
+ }
554
+ if (platform === "web") {
555
+ const resolvedProfile = resolveProfile(config, profile);
556
+ 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.");
557
+ return;
558
+ }
559
+ if (platform === "harmony") {
560
+ assert(hasHarmonyHost(root), "HARMONY_HOST_REQUIRED", "No complete HarmonyOS host was found. Add an official Lynx Harmony host under harmony/ before building.");
561
+ return;
562
+ }
563
+ if (platform === "desktop") {
564
+ const resolvedProfile = resolveProfile(config, profile);
565
+ 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.");
566
+ return;
567
+ }
568
+ 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.");
569
+ const resolvedProfile = resolveProfile(config, profile);
570
+ if (hasIosHost(root, resolvedProfile))
571
+ return;
572
+ 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.");
573
+ const suggestedId = suggestedIosBundleIdentifier(root);
574
+ const bundleIdentifier = flag("--bundle-identifier") ??
575
+ (ui.interactive ? await prompt("iOS bundle identifier", suggestedId) : "");
576
+ 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.`);
577
+ ui.info("No iOS host found. Creating the official LynxShip host before the build…");
578
+ const result = await initializeIosHost(root, {
579
+ bundleIdentifier,
580
+ appName: basename(root),
581
+ });
582
+ ui.success(`iOS host created: ${result.directory}`);
583
+ }
472
584
  function forwardedToolArgs(values) {
473
585
  const result = [];
474
586
  const valueFlags = new Set([
@@ -507,10 +619,34 @@ async function runRspeedyCommand(subcommand, environment) {
507
619
  if (subcommand === "dev") {
508
620
  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
621
  }
622
+ let devUrl;
623
+ let devQrPrinted = false;
624
+ let devQrTimer;
625
+ const printDevQr = () => {
626
+ if (!devUrl || devQrPrinted || json || ui.options.quiet)
627
+ return;
628
+ devQrPrinted = true;
629
+ if (devQrTimer)
630
+ clearTimeout(devQrTimer);
631
+ ui.devServerQr(devUrl);
632
+ };
510
633
  await runRspeedy(root, subcommand, forwarded, {
511
634
  env: environment,
512
635
  quiet: json,
513
- onOutput: (line) => ui.info(`│ ${line}`),
636
+ onOutput: (line) => {
637
+ ui.info(`│ ${line}`);
638
+ if (subcommand !== "dev")
639
+ return;
640
+ const url = extractDevServerUrl(line);
641
+ if (url && (!devUrl || url.includes("fullscreen=true"))) {
642
+ devUrl = url;
643
+ if (devQrTimer)
644
+ clearTimeout(devQrTimer);
645
+ devQrTimer = setTimeout(printDevQr, 500);
646
+ }
647
+ if (devUrl && shouldPrintDevServerQr(line))
648
+ printDevQr();
649
+ },
514
650
  });
515
651
  printValue({ status: "success", command: `rspeedy ${subcommand}` }, {
516
652
  title: `Rspeedy ${subcommand}`,
@@ -541,6 +677,33 @@ async function runAutolinkCodegen() {
541
677
  done: "Native Module specifications were generated successfully.",
542
678
  });
543
679
  }
680
+ async function runDevToolDoctor(kind) {
681
+ await initializeBuildProject();
682
+ const platform = mobilePlatformValue(flag("--platform", "android"));
683
+ const status = await inspectLynxDevTool(root, platform);
684
+ const ready = kind === "devtool"
685
+ ? status.ok
686
+ : kind === "trace"
687
+ ? status.traceReady
688
+ : status.recorderReady;
689
+ if (!ready)
690
+ process.exitCode = 1;
691
+ printValue({ ...status, requested: kind, ready }, {
692
+ title: `Lynx ${kind === "devtool" ? "DevTool" : kind[0].toUpperCase() + kind.slice(1)} · ${platform}`,
693
+ rows: status.checks.map((item) => ({
694
+ label: item.name,
695
+ value: `${item.status} · ${item.value}${item.fix && item.status !== "pass" ? ` · fix: ${item.fix}` : ""}`,
696
+ valueColor: item.status === "pass"
697
+ ? "green"
698
+ : item.status === "warn"
699
+ ? "yellow"
700
+ : "red",
701
+ })),
702
+ done: ready
703
+ ? `${kind} prerequisites are ready. Open Lynx DevTool Desktop and connect the target device by USB.`
704
+ : `Lynx ${kind} prerequisites are incomplete: ${formatDevToolFailure(status) || "review the failed checks"}`,
705
+ });
706
+ }
544
707
  async function runDevice() {
545
708
  const platform = platformValue(flag("--platform", "android"));
546
709
  const artifact = flag("--artifact");
@@ -563,7 +726,7 @@ async function runDevice() {
563
726
  onOutput: (line) => ui.info(`│ ${line}`),
564
727
  });
565
728
  }
566
- else {
729
+ else if (platform === "ios") {
567
730
  assert(process.platform === "darwin", "IOS_MACOS_REQUIRED", "iOS device/simulator commands require macOS");
568
731
  assert(commandExists("xcrun"), "IOS_XCRUN_REQUIRED", "xcrun was not found in PATH");
569
732
  const device = flag("--device", "booted");
@@ -592,6 +755,23 @@ async function runDevice() {
592
755
  });
593
756
  }
594
757
  }
758
+ else if (platform === "harmony") {
759
+ assert(commandExists("hdc"), "HARMONY_HDC_REQUIRED", "hdc was not found in PATH. Install the OpenHarmony SDK platform tools before installing a HAP.");
760
+ assert(artifactPath.endsWith(".hap"), "DEVICE_ARTIFACT_INVALID", "HarmonyOS run requires a signed .hap artifact.");
761
+ const device = flag("--device");
762
+ await runProcess("hdc", device
763
+ ? ["-t", device, "install", "-r", artifactPath]
764
+ : ["install", "-r", artifactPath], {
765
+ cwd: root,
766
+ quiet: json,
767
+ onOutput: (line) => ui.info(`│ ${line}`),
768
+ });
769
+ }
770
+ else {
771
+ assert(false, "TARGET_RUN_UNSUPPORTED", platform === "web"
772
+ ? "Web artifacts are previewed with `lynxship preview` or served by the project; they are not installed on a device."
773
+ : "Desktop installers are launched by the operating system after packaging; LynxShip does not claim a cross-platform install command.");
774
+ }
595
775
  printValue({ status: "installed", platform, artifact: artifactPath }, {
596
776
  title: "Device install",
597
777
  rows: [{ label: "Artifact", value: artifactPath, valueColor: "green" }],
@@ -600,7 +780,7 @@ async function runDevice() {
600
780
  }
601
781
  async function streamNativeLogs() {
602
782
  const platform = platformValue(flag("--platform", "android"));
603
- const device = flag("--device", platform === "android" ? undefined : "booted");
783
+ const device = flag("--device", platform === "ios" ? "booted" : undefined);
604
784
  if (platform === "android") {
605
785
  assert(commandExists("adb"), "ANDROID_ADB_REQUIRED", "adb was not found in PATH");
606
786
  await runProcess("adb", device ? ["-s", device, "logcat"] : ["logcat"], {
@@ -609,7 +789,7 @@ async function streamNativeLogs() {
609
789
  onOutput: (line) => ui.info(`│ ${line}`),
610
790
  });
611
791
  }
612
- else {
792
+ else if (platform === "ios") {
613
793
  assert(process.platform === "darwin", "IOS_MACOS_REQUIRED", "iOS logs require macOS");
614
794
  assert(commandExists("xcrun"), "IOS_XCRUN_REQUIRED", "xcrun was not found in PATH");
615
795
  assert(device !== "booted", "IOS_DEVICE_LOGS_UNSUPPORTED", "Use --device <simulator-identifier> for iOS simulator logs.");
@@ -625,6 +805,22 @@ async function streamNativeLogs() {
625
805
  "debug",
626
806
  ], { cwd: root, quiet: json, onOutput: (line) => ui.info(`│ ${line}`) });
627
807
  }
808
+ else if (platform === "harmony") {
809
+ assert(commandExists("hdc"), "HARMONY_HDC_REQUIRED", "hdc was not found in PATH. Install the OpenHarmony SDK platform tools before streaming logs.");
810
+ const argsForHdc = device
811
+ ? ["-t", device, "shell", "hilog"]
812
+ : ["shell", "hilog"];
813
+ await runProcess("hdc", argsForHdc, {
814
+ cwd: root,
815
+ quiet: json,
816
+ onOutput: (line) => ui.info(`│ ${line}`),
817
+ });
818
+ }
819
+ else {
820
+ assert(false, "TARGET_LOGS_UNSUPPORTED", platform === "web"
821
+ ? "Web logs are emitted by the browser/runtime console; use lynxship dev or the project's Web DevTools."
822
+ : "Desktop logs are emitted by the packaged runtime; use the target OS logging tools.");
823
+ }
628
824
  }
629
825
  async function main() {
630
826
  const command = args.shift() ?? "help";
@@ -678,12 +874,42 @@ async function main() {
678
874
  const config = await loadConfig(root);
679
875
  const configuration = await readConfigurationStatus();
680
876
  const doctorPlatform = platformValue(flag("--platform", "android"));
681
- const autolink = await inspectAutolink(root);
682
- const autolinkForPlatform = autolink[doctorPlatform];
877
+ const autolink = doctorPlatform === "android" || doctorPlatform === "ios"
878
+ ? await inspectAutolink(root)
879
+ : undefined;
880
+ const autolinkForPlatform = autolink && (doctorPlatform === "android" || doctorPlatform === "ios")
881
+ ? autolink[doctorPlatform]
882
+ : undefined;
683
883
  const lockfile = await findLockfile(root);
684
884
  const credentialStore = credentialStorageDescription();
685
885
  const nativeCredentialStore = !credentialStore.includes("owner-only");
686
886
  const androidHost = doctorPlatform === "android" ? await hasAndroidHost(root) : false;
887
+ const doctorProfileName = flag("--profile", "production");
888
+ const doctorProfile = config.build?.[doctorProfileName]
889
+ ? resolveProfile(config, doctorProfileName)
890
+ : {
891
+ name: doctorProfileName,
892
+ ...(DEFAULT_CONFIG.build?.production ?? {}),
893
+ };
894
+ const targetToolchain = doctorPlatform === "web"
895
+ ? await inspectWebTarget(root, doctorProfile)
896
+ : doctorPlatform === "harmony"
897
+ ? await inspectHarmonyTarget(root, doctorProfile)
898
+ : doctorPlatform === "desktop"
899
+ ? await inspectDesktopTarget(root, doctorProfile)
900
+ : undefined;
901
+ assert(!args.includes("--fix") || doctorPlatform === "android", "CLI_DOCTOR_FIX_PLATFORM", "`doctor --fix` currently repairs Android SDK packages; run it with `--platform android`.");
902
+ assert(!args.includes("--fix") || ui.interactive, "CLI_INTERACTIVE_REQUIRED", "Run `lynxship doctor --platform android --fix` in an interactive terminal.");
903
+ let androidToolchain = doctorPlatform === "android"
904
+ ? await inspectAndroidToolchain(root)
905
+ : undefined;
906
+ const iosToolchain = doctorPlatform === "ios"
907
+ ? await inspectIosToolchain(root, doctorProfile)
908
+ : undefined;
909
+ if (androidToolchain && args.includes("--fix")) {
910
+ await fixAndroidToolchain(root, androidToolchain, (message) => confirm(message), (line) => ui.info(line));
911
+ androidToolchain = await inspectAndroidToolchain(root);
912
+ }
687
913
  const nodeMajor = Number(process.versions.node.split(".")[0]);
688
914
  const nodeSupported = nodeMajor >= 22;
689
915
  const nodeRecommended = nodeMajor % 2 === 0;
@@ -732,8 +958,34 @@ async function main() {
732
958
  ? "Gradle host found"
733
959
  : "missing · fix: lynxship android host init --application-id com.example.myapp",
734
960
  },
961
+ ...(androidToolchain?.checks ?? []).map((toolchainCheck) => ({
962
+ name: toolchainCheck.name,
963
+ ok: toolchainCheck.ok,
964
+ status: toolchainCheck.status,
965
+ value: toolchainCheck.fix && toolchainCheck.status !== "pass"
966
+ ? `${toolchainCheck.value} · fix: ${toolchainCheck.fix}`
967
+ : toolchainCheck.value,
968
+ })),
735
969
  ]
736
- : []),
970
+ : doctorPlatform === "ios"
971
+ ? (iosToolchain?.checks ?? [])
972
+ .filter((toolchainCheck) => toolchainCheck.name !== "ios-host")
973
+ .map((toolchainCheck) => ({
974
+ name: toolchainCheck.name,
975
+ ok: toolchainCheck.ok,
976
+ status: toolchainCheck.status,
977
+ value: toolchainCheck.fix && toolchainCheck.status !== "pass"
978
+ ? `${toolchainCheck.value} · fix: ${toolchainCheck.fix}`
979
+ : toolchainCheck.value,
980
+ }))
981
+ : (targetToolchain?.checks ?? []).map((toolchainCheck) => ({
982
+ name: toolchainCheck.name,
983
+ ok: toolchainCheck.ok,
984
+ status: toolchainCheck.status,
985
+ value: toolchainCheck.fix && toolchainCheck.status !== "pass"
986
+ ? `${toolchainCheck.value} · fix: ${toolchainCheck.fix}`
987
+ : toolchainCheck.value,
988
+ }))),
737
989
  {
738
990
  name: "cloudflare-r2",
739
991
  ok: configuration.r2,
@@ -742,32 +994,47 @@ async function main() {
742
994
  ? "configured"
743
995
  : "missing · fix: lynxship storage configure",
744
996
  },
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
- },
997
+ ...(doctorPlatform === "android"
998
+ ? [
999
+ {
1000
+ name: "android-signing",
1001
+ ok: configuration.android,
1002
+ status: configuration.android
1003
+ ? "pass"
1004
+ : "fail",
1005
+ value: configuration.android
1006
+ ? "configured"
1007
+ : "missing · fix: lynxship android configure",
1008
+ },
1009
+ ]
1010
+ : doctorPlatform === "ios"
1011
+ ? [
1012
+ {
1013
+ name: "ios-host",
1014
+ ok: process.platform === "darwin" && hasIosHost(root),
1015
+ status: process.platform === "darwin" && hasIosHost(root)
1016
+ ? "pass"
1017
+ : "fail",
1018
+ value: process.platform === "darwin" && hasIosHost(root)
1019
+ ? "Xcode host found"
1020
+ : "missing · fix: use macOS, then lynxship ios host init --bundle-identifier com.example.myapp",
1021
+ },
1022
+ ]
1023
+ : []),
1024
+ ...(autolinkForPlatform
1025
+ ? [
1026
+ {
1027
+ name: `lynx-autolink-${doctorPlatform}`,
1028
+ ok: autolinkForPlatform.ready,
1029
+ status: autolinkForPlatform.ready
1030
+ ? "pass"
1031
+ : "fail",
1032
+ value: autolinkForPlatform.ready
1033
+ ? autolinkForPlatform.reason
1034
+ : `${autolinkForPlatform.reason} · fix: install the native plugin, then run autolink codegen`,
1035
+ },
1036
+ ]
1037
+ : []),
771
1038
  ];
772
1039
  const result = {
773
1040
  ok: checks.every((check) => check.status !== "fail"),
@@ -778,6 +1045,8 @@ async function main() {
778
1045
  ui.warn("One or more environment checks failed");
779
1046
  else if (hasWarnings)
780
1047
  ui.warn("Environment is usable, but one recommendation needs attention");
1048
+ if (!result.ok)
1049
+ process.exitCode = 1;
781
1050
  printValue(result, {
782
1051
  title: "Doctor result",
783
1052
  rows: checks.map((check) => ({
@@ -808,6 +1077,12 @@ async function main() {
808
1077
  });
809
1078
  return;
810
1079
  }
1080
+ if (["devtool", "trace", "recorder"].includes(command)) {
1081
+ const subcommand = args.shift() ?? "doctor";
1082
+ assert(subcommand === "doctor", "CLI_DEVTOOL_COMMAND", "Use `lynxship devtool doctor`, `lynxship trace doctor` or `lynxship recorder doctor`.");
1083
+ await runDevToolDoctor(command);
1084
+ return;
1085
+ }
811
1086
  if (command === "autolink") {
812
1087
  const subcommand = args.shift() ?? "check";
813
1088
  assert(["check", "codegen"].includes(subcommand), "CLI_AUTOLINK_COMMAND", "Use `lynxship autolink check` or `lynxship autolink codegen`");
@@ -815,7 +1090,7 @@ async function main() {
815
1090
  await runAutolinkCodegen();
816
1091
  return;
817
1092
  }
818
- const platform = platformValue(flag("--platform", "android"));
1093
+ const platform = mobilePlatformValue(flag("--platform", "android"));
819
1094
  const status = (await inspectAutolink(root))[platform];
820
1095
  printValue(status, {
821
1096
  title: `Lynx Autolink · ${platform}`,
@@ -844,7 +1119,7 @@ async function main() {
844
1119
  }
845
1120
  if (command === "ota") {
846
1121
  assert((args.shift() ?? "doctor") === "doctor", "CLI_OTA_COMMAND", "Only `lynxship ota doctor` is available");
847
- const platform = platformValue(flag("--platform", "android"));
1122
+ const platform = mobilePlatformValue(flag("--platform", "android"));
848
1123
  const status = await inspectOtaHost(root, platform);
849
1124
  printValue(status, {
850
1125
  title: `OTA host · ${platform}`,
@@ -1026,7 +1301,7 @@ async function main() {
1026
1301
  if (command === "store") {
1027
1302
  assert(ui.interactive, "CLI_INTERACTIVE_REQUIRED", "Run store configure in an interactive terminal");
1028
1303
  assert((args.shift() ?? "configure") === "configure", "CLI_STORE_COMMAND", "Only store configure is available");
1029
- const platform = platformValue(flag("--platform", "android"));
1304
+ const platform = mobilePlatformValue(flag("--platform", "android"));
1030
1305
  ui.info(platform === "android"
1031
1306
  ? "Configuring Google Play submission. Secret fields will stay invisible…"
1032
1307
  : "Configuring App Store Connect submission. Private key contents stay protected…");
@@ -1066,7 +1341,7 @@ async function main() {
1066
1341
  const { state, repository, builds, submissions } = await loadState();
1067
1342
  if (command === "submit") {
1068
1343
  const config = await loadConfig(root);
1069
- const platform = platformValue(flag("--platform", "android"));
1344
+ const platform = mobilePlatformValue(flag("--platform", "android"));
1070
1345
  await requireOperationalConfiguration(platform);
1071
1346
  const credentials = await loadCredentials(root);
1072
1347
  const localMode = args.includes("--local") || process.env.LYNXSHIP_SUBMIT_MODE === "mock";
@@ -1151,7 +1426,7 @@ async function main() {
1151
1426
  }
1152
1427
  if (command === "update" && args[0] !== "rollback") {
1153
1428
  const config = await loadConfig(root);
1154
- const platform = platformValue(flag("--platform", "android"));
1429
+ const platform = mobilePlatformValue(flag("--platform", "android"));
1155
1430
  await requireOperationalConfiguration(platform);
1156
1431
  const projectId = configuredProjectId(config);
1157
1432
  const localMode = args.includes("--local") || process.env.LYNXSHIP_SUBMIT_MODE === "mock";
@@ -1273,7 +1548,7 @@ async function main() {
1273
1548
  if (command === "update")
1274
1549
  args.shift();
1275
1550
  const config = await loadConfig(root);
1276
- const platform = platformValue(flag("--platform", "android"));
1551
+ const platform = mobilePlatformValue(flag("--platform", "android"));
1277
1552
  const releaseId = flag("--release-id");
1278
1553
  const reason = flag("--reason");
1279
1554
  const channel = config.update?.channel ?? "production";
@@ -1345,6 +1620,8 @@ async function main() {
1345
1620
  const buildAll = subcommand === "all" || platformArgument === "all";
1346
1621
  const platform = buildAll ? "android" : platformValue(platformArgument);
1347
1622
  const skipUpload = args.includes("--no-upload");
1623
+ const allowUnsigned = args.includes("--allow-unsigned");
1624
+ assert(!allowUnsigned || skipUpload, "CLI_UNSIGNED_UPLOAD_BLOCKED", "--allow-unsigned is only available with --no-upload and can never upload an unsigned Desktop artifact.");
1348
1625
  await requireOperationalConfiguration(platform, { requireR2: !skipUpload });
1349
1626
  if (subcommand === "list") {
1350
1627
  printValue(builds.list());
@@ -1372,11 +1649,13 @@ async function main() {
1372
1649
  const profile = flag("--profile", "production");
1373
1650
  const wait = !args.includes("--no-wait");
1374
1651
  const local = args.includes("--local");
1375
- const platforms = buildAll ? ["android", "ios"] : [platform];
1652
+ const platforms = buildAll
1653
+ ? ["android", "ios", "harmony", "web", "desktop"]
1654
+ : [platform];
1376
1655
  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`.");
1656
+ 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.");
1657
+ for (const target of platforms)
1658
+ await ensureNativeHostForBuild(target, profile, config);
1380
1659
  }
1381
1660
  if (!buildAll) {
1382
1661
  await executeBuild({
@@ -1384,6 +1663,7 @@ async function main() {
1384
1663
  profile,
1385
1664
  platform,
1386
1665
  skipUpload,
1666
+ allowUnsigned,
1387
1667
  wait,
1388
1668
  local,
1389
1669
  state,
@@ -1395,21 +1675,24 @@ async function main() {
1395
1675
  }
1396
1676
  if (buildAll && wait && !local)
1397
1677
  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";
1678
+ const progress = ui.progress("All Lynx targets build");
1679
+ const progressValues = {};
1680
+ const progressLabels = {};
1681
+ const platformName = (target) => target === "android"
1682
+ ? "Android"
1683
+ : target === "ios"
1684
+ ? "iOS"
1685
+ : target === "harmony"
1686
+ ? "HarmonyOS"
1687
+ : target === "web"
1688
+ ? "Web"
1689
+ : "Desktop";
1408
1690
  const outcomes = await Promise.allSettled(platforms.map((target) => executeBuild({
1409
1691
  config,
1410
1692
  profile,
1411
1693
  platform: target,
1412
1694
  skipUpload,
1695
+ allowUnsigned,
1413
1696
  wait,
1414
1697
  local,
1415
1698
  state,
@@ -1418,18 +1701,18 @@ async function main() {
1418
1701
  submissions,
1419
1702
  progress,
1420
1703
  progressPrefix: platformName(target),
1421
- skipBundleBuild: wait && !local,
1704
+ skipBundleBuild: wait && !local && target !== "web",
1422
1705
  onEvent: (message) => progress.event(`${platformName(target)} · ${message}`),
1423
1706
  onProgress: (value, label) => {
1424
1707
  if (value !== undefined)
1425
1708
  progressValues[target] = value;
1426
1709
  if (label)
1427
1710
  progressLabels[target] = label;
1428
- const average = (progressValues.android + progressValues.ios) / platforms.length;
1711
+ const average = platforms.reduce((total, current) => total + (progressValues[current] ?? 0), 0) / platforms.length;
1429
1712
  progress.update(average, `${platformName(target)} · ${progressLabels[target]}`);
1430
1713
  },
1431
1714
  }, false)));
1432
- progress.update(100, "Android and iOS builds finished");
1715
+ progress.update(100, "All Lynx target builds finished");
1433
1716
  progress.stop();
1434
1717
  const summaryBuilds = outcomes.map((outcome, index) => {
1435
1718
  if (outcome.status === "fulfilled")
@@ -1466,7 +1749,7 @@ async function main() {
1466
1749
  })),
1467
1750
  done: failed
1468
1751
  ? "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.",
1752
+ : "All Lynx target build jobs completed. Submit supported store artifacts separately.",
1470
1753
  });
1471
1754
  for (const result of summaryBuilds)
1472
1755
  if (result.state === "success" &&
@@ -1482,6 +1765,8 @@ function exitCode(error) {
1482
1765
  return 2;
1483
1766
  if (code === "BUILD_SIGNING_REQUIRED")
1484
1767
  return 2;
1768
+ if (code === "DESKTOP_SIGNING_REQUIRED")
1769
+ return 2;
1485
1770
  if (code?.startsWith("AUTH_"))
1486
1771
  return 4;
1487
1772
  if (code?.startsWith("BUILD_"))