@lynxship/cli 0.1.6 → 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.
package/README.md CHANGED
@@ -120,6 +120,7 @@ doctor Check the local toolchain and project
120
120
  dev Run Rspeedy dev with Lynx Explorer QR/HMR
121
121
  preview Preview the production bundle locally
122
122
  build create Build, sign and upload an artifact
123
+ build all Build Android and iOS on a macOS host
123
124
  build list List build jobs
124
125
  build status <id> Inspect one build job
125
126
  build cancel <id> Cancel a build job
@@ -143,6 +144,47 @@ Use `lynxship --help` or `lynxship <command> --help` for the complete option
143
144
  list. `--json`, `--quiet`, `--no-color` and `--non-interactive` are available
144
145
  for automation.
145
146
 
147
+ ### Actionable error guidance
148
+
149
+ Recoverable errors print a `Next steps` section with the exact commands to
150
+ run. For example, a pure LynxJS project without a native Android host reports
151
+ the two supported paths instead of stopping at a generic Gradle error:
152
+
153
+ ```text
154
+ x This project has no Android Gradle host...
155
+
156
+ Next steps
157
+ 1. lynxship dev
158
+ 2. lynxship android host init --application-id com.example.myapp
159
+ 3. lynxship doctor --platform android
160
+ 4. lynxship build --platform android --profile production
161
+ ```
162
+
163
+ `lynxship dev` is for Lynx Explorer and live QR/HMR development. The Android
164
+ host command creates the native Gradle project needed for a real APK/AAB.
165
+ `--local` remains a contract-test mode and never creates a fake artifact.
166
+
167
+ To build both native platforms in one local workflow, use the multi-platform
168
+ selector:
169
+
170
+ ```bash
171
+ lynxship build --platform all --profile production
172
+ # equivalent convenience form:
173
+ lynxship build all --profile production
174
+ ```
175
+
176
+ This builds Android and iOS concurrently after one shared Lynx bundle step.
177
+ Their jobs, progress events and artifacts remain isolated. A real local `all`
178
+ build requires macOS, an Android host, an iOS Xcode host and the corresponding
179
+ signing setup. Windows and Linux can still run
180
+ `lynxship build --platform android`; use a macOS CI worker for the iOS half.
181
+ `--local` can exercise both contract paths without creating APK or IPA files.
182
+
183
+ The same guidance covers missing R2 or signing setup, Android SDK tools,
184
+ Autolink/codegen, iOS Xcode/CocoaPods, device tools, store credentials and
185
+ OTA compatibility. In automation, use `--json`; failures include a
186
+ machine-readable `nextSteps` array and optional `note` field.
187
+
146
188
  ## OTA rollback
147
189
 
148
190
  ```bash
@@ -4,6 +4,7 @@ interface AndroidBuildOptions {
4
4
  root: string;
5
5
  profile: BuildProfile;
6
6
  uploadArtifacts?: boolean;
7
+ skipBundleBuild?: boolean;
7
8
  quiet?: boolean;
8
9
  onStep?: (message: string) => void;
9
10
  onEvent?: (message: string) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"android-build.d.ts","sourceRoot":"","sources":["../src/android-build.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAEpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAWhD,UAAU,mBAAmB;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,YAAY,CAAC;IACtB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,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;AA0LD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAa7D;AAED,wBAAgB,0BAA0B,IAAI,OAAO,CAEpD;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAsCpE;AAED,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,QAAQ,CAAC,CAgKnB"}
1
+ {"version":3,"file":"android-build.d.ts","sourceRoot":"","sources":["../src/android-build.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAEpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAWhD,UAAU,mBAAmB;IAC3B,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,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,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;AA8KD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAa7D;AAED,wBAAgB,0BAA0B,IAAI,OAAO,CAEpD;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAsCpE;AAED,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,QAAQ,CAAC,CA+JnB"}
@@ -8,18 +8,8 @@ import { transitionBuild } from "@lynxship/build-orchestrator";
8
8
  import { loadR2, uploadR2Artifact } from "./r2.js";
9
9
  import { loadCredentials } from "./secure-store.js";
10
10
  import { nativeArtifactName } from "./artifact-name.js";
11
- import { commandExists, executableExists, packageManagerScriptCommand, runProcess, } from "./process-runner.js";
12
- async function projectBuildCommand(root) {
13
- try {
14
- const packageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
15
- if (packageJson.scripts?.["build:mobile"])
16
- return ["run", "build:mobile"];
17
- }
18
- catch {
19
- // The package manager will report the useful project error below.
20
- }
21
- return ["run", "build"];
22
- }
11
+ import { commandExists, executableExists, runProcess, } from "./process-runner.js";
12
+ import { buildLynxBundle } from "./bundle-build.js";
23
13
  async function signingEnvironment(root) {
24
14
  const android = (await loadCredentials(root)).android;
25
15
  const values = {
@@ -220,13 +210,16 @@ export async function runRealAndroidBuild(job, options) {
220
210
  message: "rspeedy:build",
221
211
  at: new Date().toISOString(),
222
212
  });
223
- step("Building Lynx bundle with Rspeedy…");
224
- const packageManager = packageManagerScriptCommand(options.root, (await projectBuildCommand(options.root))[1] ?? "build");
225
- await runProcess(packageManager.command, packageManager.args, {
226
- cwd: options.root,
227
- env: environment,
228
- ...processOptions,
229
- });
213
+ if (options.skipBundleBuild) {
214
+ step("Using shared Lynx bundle", 20);
215
+ }
216
+ else {
217
+ step("Building Lynx bundle with Rspeedy…");
218
+ await buildLynxBundle(options.root, {
219
+ env: environment,
220
+ ...processOptions,
221
+ });
222
+ }
230
223
  step("Rspeedy bundle ready", 20);
231
224
  transitionBuild(job, "queued", "Android build queued locally");
232
225
  step("Build queued…");
@@ -0,0 +1,7 @@
1
+ export interface BundleBuildOptions {
2
+ env?: NodeJS.ProcessEnv;
3
+ quiet?: boolean;
4
+ onOutput?: (line: string) => void;
5
+ }
6
+ export declare function buildLynxBundle(root: string, options?: BundleBuildOptions): Promise<void>;
7
+ //# sourceMappingURL=bundle-build.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundle-build.d.ts","sourceRoot":"","sources":["../src/bundle-build.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,kBAAkB;IACjC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAcD,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CASf"}
@@ -0,0 +1,24 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { packageManagerScriptCommand, runProcess } from "./process-runner.js";
4
+ async function resolveBundleScript(root) {
5
+ try {
6
+ const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
7
+ if (manifest.scripts?.["build:mobile"])
8
+ return "build:mobile";
9
+ }
10
+ catch {
11
+ // The package manager will report the useful project error below.
12
+ }
13
+ return "build";
14
+ }
15
+ export async function buildLynxBundle(root, options = {}) {
16
+ const script = await resolveBundleScript(root);
17
+ const packageManager = packageManagerScriptCommand(root, script);
18
+ await runProcess(packageManager.command, packageManager.args, {
19
+ cwd: root,
20
+ env: options.env,
21
+ quiet: options.quiet,
22
+ onOutput: options.onOutput,
23
+ });
24
+ }
@@ -0,0 +1,6 @@
1
+ export interface CliGuidance {
2
+ commands: string[];
3
+ note?: string;
4
+ }
5
+ export declare function guidanceForError(error: unknown): CliGuidance;
6
+ //# sourceMappingURL=guidance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guidance.d.ts","sourceRoot":"","sources":["../src/guidance.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAoQD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,WAAW,CAiG5D"}
@@ -0,0 +1,354 @@
1
+ const guidance = {
2
+ CLI_PROJECT_REQUIRED: {
3
+ commands: ["lynxship init", "lynxship doctor"],
4
+ note: "Run them from the project directory or add --project-dir <path>.",
5
+ },
6
+ CLI_PROJECT_ID_REQUIRED: {
7
+ commands: ["lynxship init", "lynxship doctor"],
8
+ },
9
+ CLI_R2_REQUIRED: {
10
+ commands: ["lynxship storage configure", "lynxship doctor"],
11
+ },
12
+ CLI_R2_ACCOUNT_ID: {
13
+ commands: ["lynxship storage configure"],
14
+ note: "Use the 32-character Cloudflare account ID.",
15
+ },
16
+ CLI_R2_BUCKET: {
17
+ commands: ["lynxship storage configure"],
18
+ note: "Use a lowercase R2 bucket name with no spaces or underscores.",
19
+ },
20
+ CLI_R2_ENDPOINT: {
21
+ commands: ["lynxship storage configure"],
22
+ note: "Use the HTTPS S3 endpoint for the same Cloudflare account.",
23
+ },
24
+ CLI_R2_EXPIRY: {
25
+ commands: ["lynxship storage configure"],
26
+ note: "Choose a download lifetime between 1 second and 7 days.",
27
+ },
28
+ CLI_R2_CREDENTIALS: {
29
+ commands: ["lynxship storage configure", "lynxship doctor"],
30
+ },
31
+ BUILD_SIGNING_REQUIRED: {
32
+ commands: [
33
+ "lynxship android configure",
34
+ "lynxship doctor --platform android",
35
+ ],
36
+ note: "An existing .jks/.keystore is supported; production builds need a real release key.",
37
+ },
38
+ LYNXSHIP_KEYSTORE_PATH: {
39
+ commands: ["lynxship android configure"],
40
+ },
41
+ LYNXSHIP_KEY_ALIAS: {
42
+ commands: ["lynxship android configure"],
43
+ },
44
+ LYNXSHIP_KEYSTORE_PASSWORD: {
45
+ commands: ["lynxship android configure"],
46
+ },
47
+ LYNXSHIP_KEY_PASSWORD: {
48
+ commands: ["lynxship android configure"],
49
+ },
50
+ ANDROID_HOST_REQUIRED: {
51
+ commands: [
52
+ "lynxship dev",
53
+ "lynxship android host init --application-id com.example.myapp",
54
+ "lynxship doctor --platform android",
55
+ "lynxship build --platform android --profile production",
56
+ ],
57
+ note: "Use dev for Lynx Explorer. The host init path is required for a real APK/AAB; --local is only a contract test.",
58
+ },
59
+ ANDROID_HOST_EXISTS: {
60
+ commands: ["lynxship doctor --platform android"],
61
+ note: "Review the existing android/ host instead of overwriting it.",
62
+ },
63
+ ANDROID_APPLICATION_ID_INVALID: {
64
+ commands: ["lynxship android host init --application-id com.example.myapp"],
65
+ note: "Use a reverse-domain Android application ID, for example com.company.app.",
66
+ },
67
+ ANDROID_PLATFORM_UNSUPPORTED: {
68
+ commands: ["lynxship doctor --platform android"],
69
+ note: "Android builds run on Windows, macOS or Linux. Use macOS for iOS builds.",
70
+ },
71
+ BUILD_ALL_MACOS_REQUIRED: {
72
+ commands: [
73
+ "lynxship build --platform android --profile production",
74
+ "lynxship build --platform all --profile production --no-upload",
75
+ ],
76
+ note: "Run the all-platform command on a macOS machine or macOS CI worker; the second command is for CI verification.",
77
+ },
78
+ ANDROID_APKSIGNER_REQUIRED: {
79
+ commands: [
80
+ "lynxship doctor --platform android",
81
+ 'sdkmanager "build-tools;latest"',
82
+ ],
83
+ note: "Ensure Android SDK Build Tools are installed and available through ANDROID_HOME or ANDROID_SDK_ROOT.",
84
+ },
85
+ ANDROID_JARSIGNER_REQUIRED: {
86
+ commands: ["lynxship doctor --platform android", "java -version"],
87
+ note: "Install JDK 17 or newer and make sure jarsigner is on PATH.",
88
+ },
89
+ ANDROID_ADB_REQUIRED: {
90
+ commands: ["lynxship doctor --platform android", "adb devices"],
91
+ note: "Install Android SDK Platform-Tools and connect or start a device.",
92
+ },
93
+ LYNX_BUNDLE_MISSING: {
94
+ commands: ["lynxship dev", "lynxship build --platform android"],
95
+ note: "Build the Lynx bundle with the project's configured package manager.",
96
+ },
97
+ IOS_HOST_REQUIRED: {
98
+ commands: [
99
+ "lynxship dev",
100
+ "lynxship ios host init --bundle-identifier com.example.myapp",
101
+ "lynxship doctor --platform ios",
102
+ "lynxship build --platform ios --profile production",
103
+ ],
104
+ note: "Use dev for Lynx Explorer. A real IPA requires macOS, Xcode and an iOS host.",
105
+ },
106
+ IOS_HOST_EXISTS: {
107
+ commands: ["lynxship doctor --platform ios"],
108
+ note: "Review the existing ios/ host instead of overwriting it.",
109
+ },
110
+ IOS_BUNDLE_IDENTIFIER_INVALID: {
111
+ commands: ["lynxship ios host init --bundle-identifier com.example.myapp"],
112
+ note: "Use a reverse-domain bundle identifier, for example com.company.app.",
113
+ },
114
+ IOS_MACOS_REQUIRED: {
115
+ commands: ["lynxship doctor --platform ios"],
116
+ note: "Run real iOS commands on macOS. Windows and Linux cannot produce an IPA.",
117
+ },
118
+ IOS_XCODE_REQUIRED: {
119
+ commands: ["xcode-select --install", "lynxship doctor --platform ios"],
120
+ note: "Install Xcode from the Mac App Store and select its command-line tools.",
121
+ },
122
+ IOS_XCRUN_REQUIRED: {
123
+ commands: ["xcode-select --install", "lynxship doctor --platform ios"],
124
+ },
125
+ IOS_COCOAPODS_REQUIRED: {
126
+ commands: ["brew install cocoapods", "lynxship build --platform ios"],
127
+ note: "CocoaPods is required when the iOS host contains a Podfile.",
128
+ },
129
+ IOS_PROJECT_REQUIRED: {
130
+ commands: [
131
+ "lynxship ios host init --bundle-identifier com.example.myapp",
132
+ "lynxship doctor --platform ios",
133
+ ],
134
+ },
135
+ IOS_SCHEME_REQUIRED: {
136
+ commands: ["lynxship inspect", "lynxship doctor --platform ios"],
137
+ note: "Set build.<profile>.ios.scheme in lynxship.json.",
138
+ },
139
+ IOS_EXPORT_OPTIONS_REQUIRED: {
140
+ commands: [
141
+ "lynxship ios host init --bundle-identifier com.example.myapp",
142
+ "lynxship doctor --platform ios",
143
+ ],
144
+ note: "Set build.<profile>.ios.exportOptionsPlist to a valid export options file.",
145
+ },
146
+ IOS_DEVICE_REQUIRED: {
147
+ commands: [
148
+ "xcrun devicectl list devices",
149
+ "lynxship run --platform ios --device <device-id>",
150
+ ],
151
+ },
152
+ IOS_DEVICE_LOGS_UNSUPPORTED: {
153
+ commands: [
154
+ "xcrun simctl list devices",
155
+ "lynxship logs --platform ios --device <simulator-id>",
156
+ ],
157
+ },
158
+ DEVICE_ARTIFACT_REQUIRED: {
159
+ commands: [
160
+ "lynxship build --platform android --profile production",
161
+ "lynxship run --platform android --artifact <path-to-apk>",
162
+ ],
163
+ },
164
+ LYNX_CODEGEN_SCRIPT_REQUIRED: {
165
+ commands: [
166
+ "lynxship autolink check --platform android",
167
+ "lynxship autolink codegen --library-dir <native-library>",
168
+ ],
169
+ note: "The native library must expose its official codegen script.",
170
+ },
171
+ LYNX_AUTOLINK_ANDROID_REQUIRED: {
172
+ commands: [
173
+ "lynxship autolink check --platform android",
174
+ "lynxship autolink codegen --library-dir <native-library>",
175
+ ],
176
+ },
177
+ LYNX_AUTOLINK_IOS_REQUIRED: {
178
+ commands: [
179
+ "lynxship autolink check --platform ios",
180
+ "lynxship autolink codegen --library-dir <native-library>",
181
+ ],
182
+ },
183
+ OTA_HOST_INTEGRATION_REQUIRED: {
184
+ commands: [
185
+ "lynxship ota doctor --platform android",
186
+ "lynxship ota doctor --platform ios",
187
+ ],
188
+ note: "Integrate the LynxShip OTA client in the native host before publishing updates.",
189
+ },
190
+ OTA_NATIVE_CHANGE_REQUIRED: {
191
+ commands: [
192
+ "lynxship ota doctor --platform android",
193
+ "lynxship build --platform android --profile production",
194
+ "lynxship update --platform android --bundle dist/main.lynx.bundle",
195
+ ],
196
+ note: "Native changes require a new compatible binary before an OTA release.",
197
+ },
198
+ OTA_BUNDLE_REQUIRED: {
199
+ commands: [
200
+ "lynxship build --platform android --profile production",
201
+ "lynxship update --platform android --bundle dist/main.lynx.bundle",
202
+ ],
203
+ },
204
+ BUILD_REQUIRED: {
205
+ commands: ["lynxship build --platform android --profile production"],
206
+ },
207
+ STORE_ARTIFACT_REQUIRED: {
208
+ commands: [
209
+ "lynxship build --platform android --profile production",
210
+ "lynxship submit --platform android --latest",
211
+ ],
212
+ },
213
+ STORE_SUBMISSION_REQUIRED: {
214
+ commands: [
215
+ "lynxship store configure --platform android",
216
+ "lynxship submit --platform android --latest",
217
+ ],
218
+ },
219
+ RELEASE_NOT_FOUND: {
220
+ commands: [
221
+ "lynxship update --platform android",
222
+ "lynxship update rollback --help",
223
+ ],
224
+ },
225
+ ROLLBACK_RELEASE_REQUIRED: {
226
+ commands: [
227
+ 'lynxship update rollback --platform android --release-id <release-id> --reason "reason"',
228
+ ],
229
+ },
230
+ ROLLBACK_REASON_REQUIRED: {
231
+ commands: [
232
+ 'lynxship update rollback --platform android --release-id <release-id> --reason "reason"',
233
+ ],
234
+ },
235
+ CLI_INTERACTIVE_REQUIRED: {
236
+ commands: ["lynxship <command>"],
237
+ note: "Run without --non-interactive, or provide the documented CI environment variables.",
238
+ },
239
+ PROFILE_NOT_FOUND: {
240
+ commands: ["lynxship inspect", "lynxship build --profile production"],
241
+ note: "Use a profile declared under build in lynxship.json.",
242
+ },
243
+ PLATFORM_INVALID: {
244
+ commands: [
245
+ "lynxship build --platform android",
246
+ "lynxship build --platform ios",
247
+ "lynxship build --platform all",
248
+ ],
249
+ },
250
+ IOS_SIGNATURE_INVALID: {
251
+ commands: [
252
+ "lynxship doctor --platform ios",
253
+ "lynxship build --platform ios --profile production",
254
+ ],
255
+ note: "Check the Apple certificate, provisioning profile, team and export options.",
256
+ },
257
+ };
258
+ export function guidanceForError(error) {
259
+ const code = error.code;
260
+ if (typeof code === "string" && guidance[code])
261
+ return guidance[code];
262
+ if (typeof code === "string") {
263
+ if (code.startsWith("SUBMISSION_") || code.startsWith("AUTH_")) {
264
+ return {
265
+ commands: [
266
+ "lynxship store configure --platform android",
267
+ "lynxship submit --platform android --latest",
268
+ ],
269
+ note: "Check the provider credentials and required account permissions.",
270
+ };
271
+ }
272
+ if (code.startsWith("OTA_")) {
273
+ return {
274
+ commands: [
275
+ "lynxship ota doctor --platform android",
276
+ "lynxship update --platform android --bundle dist/main.lynx.bundle",
277
+ ],
278
+ };
279
+ }
280
+ if (code.startsWith("CONFIG_")) {
281
+ return {
282
+ commands: ["lynxship init", "lynxship doctor"],
283
+ };
284
+ }
285
+ if (code.startsWith("BUILD_")) {
286
+ return {
287
+ commands: [
288
+ "lynxship doctor --platform android",
289
+ "lynxship build --platform android --profile production",
290
+ ],
291
+ note: "Inspect the first failed build event before retrying.",
292
+ };
293
+ }
294
+ if (code.startsWith("STORE_")) {
295
+ return {
296
+ commands: [
297
+ "lynxship store configure --platform android",
298
+ "lynxship submit --platform android --latest",
299
+ ],
300
+ };
301
+ }
302
+ if (code.startsWith("IOS_")) {
303
+ return {
304
+ commands: [
305
+ "lynxship doctor --platform ios",
306
+ "lynxship build --platform ios --profile production",
307
+ ],
308
+ };
309
+ }
310
+ if (code.startsWith("ANDROID_")) {
311
+ return {
312
+ commands: [
313
+ "lynxship doctor --platform android",
314
+ "lynxship build --platform android --profile production",
315
+ ],
316
+ };
317
+ }
318
+ if (code.startsWith("CLI_")) {
319
+ return { commands: ["lynxship --help"] };
320
+ }
321
+ }
322
+ const message = error instanceof Error ? error.message : String(error);
323
+ if (/pnpm|npm|yarn|corepack|not recognized|not found/i.test(message)) {
324
+ return {
325
+ commands: ["corepack enable", "pnpm install", "lynxship doctor"],
326
+ note: "Use the package manager selected by the project's lockfile.",
327
+ };
328
+ }
329
+ if (/ENOENT|no such file or directory/i.test(message)) {
330
+ return {
331
+ commands: ["lynxship init", "lynxship doctor"],
332
+ note: "Check that the project directory and required configuration files exist.",
333
+ };
334
+ }
335
+ if (/gradle|gradlew|android sdk|build tools/i.test(message)) {
336
+ return {
337
+ commands: [
338
+ "lynxship doctor --platform android",
339
+ "lynxship build --platform android --profile production",
340
+ ],
341
+ note: "Check JDK 17, Android SDK, Build Tools and the project Gradle wrapper.",
342
+ };
343
+ }
344
+ if (/xcode|xcrun|cocoapods|pod install/i.test(message)) {
345
+ return {
346
+ commands: [
347
+ "lynxship doctor --platform ios",
348
+ "lynxship build --platform ios",
349
+ ],
350
+ note: "Check macOS, Xcode command-line tools and CocoaPods.",
351
+ };
352
+ }
353
+ return { commands: [] };
354
+ }
package/dist/index.js CHANGED
@@ -25,6 +25,8 @@ import { inspectOtaHost } from "./ota-doctor.js";
25
25
  import { otaAssetName, otaAssetPaths } from "./ota-assets.js";
26
26
  import { prompt } from "./prompt.js";
27
27
  import { commandExists, packageManagerCommand, runProcess, runRspeedy, } from "./process-runner.js";
28
+ import { guidanceForError } from "./guidance.js";
29
+ import { buildLynxBundle } from "./bundle-build.js";
28
30
  const rawArgs = process.argv.slice(2);
29
31
  const args = [...rawArgs];
30
32
  const ui = createCliUi(rawArgs);
@@ -178,9 +180,134 @@ async function loadState() {
178
180
  return { state, repository, builds, submissions };
179
181
  }
180
182
  async function saveState(state, repository, builds, submissions) {
181
- state.builds = builds.list();
182
- state.submissions = submissions.list();
183
- 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
+ }
184
311
  }
185
312
  async function initSelfHost() {
186
313
  const directory = join(root, ".lynxship");
@@ -239,6 +366,7 @@ Commands:
239
366
  build status <id> Show one build job
240
367
  build cancel <id> Cancel a build job
241
368
  build retry <id> Retry a failed build job
369
+ build all Build Android and iOS on macOS
242
370
  submit Submit the latest successful build
243
371
  update Upload and publish a signed OTA update
244
372
  update rollback Roll back an OTA channel to a previous release
@@ -251,7 +379,7 @@ Commands:
251
379
  store configure Configure Google Play or App Store Connect submission
252
380
 
253
381
  Build options:
254
- --platform <p> Target android or ios (default: android)
382
+ --platform <p> Target android, ios or all for build (default: android)
255
383
  --profile <name> Build profile (default: production)
256
384
  --no-wait Queue the build without executing it locally
257
385
  --no-upload Keep the signed artifact local and skip R2 (CI verification)
@@ -578,7 +706,7 @@ async function main() {
578
706
  name: "package-manager-lockfile",
579
707
  ok: Boolean(lockfile),
580
708
  status: lockfile ? "pass" : "fail",
581
- value: lockfile ?? "missing · fix: npm install or pnpm install",
709
+ value: lockfile ?? "missing · fix: pnpm install (or npm install)",
582
710
  },
583
711
  {
584
712
  name: "lynxship.json",
@@ -602,7 +730,7 @@ async function main() {
602
730
  status: androidHost ? "pass" : "fail",
603
731
  value: androidHost
604
732
  ? "Gradle host found"
605
- : "missing · add an Android host with android/gradlew",
733
+ : "missing · fix: lynxship android host init --application-id com.example.myapp",
606
734
  },
607
735
  ]
608
736
  : []),
@@ -612,7 +740,7 @@ async function main() {
612
740
  status: configuration.r2 ? "pass" : "fail",
613
741
  value: configuration.r2
614
742
  ? "configured"
615
- : "run lynxship storage configure",
743
+ : "missing · fix: lynxship storage configure",
616
744
  },
617
745
  {
618
746
  name: doctorPlatform === "android" ? "android-signing" : "ios-host",
@@ -627,10 +755,10 @@ async function main() {
627
755
  value: doctorPlatform === "android"
628
756
  ? configuration.android
629
757
  ? "configured"
630
- : "run lynxship android configure"
758
+ : "missing · fix: lynxship android configure"
631
759
  : process.platform === "darwin" && hasIosHost(root)
632
760
  ? "Xcode host found"
633
- : "missing · fix: use macOS with Xcode",
761
+ : "missing · fix: use macOS, then lynxship ios host init --bundle-identifier com.example.myapp",
634
762
  },
635
763
  {
636
764
  name: `lynx-autolink-${doctorPlatform}`,
@@ -1213,7 +1341,9 @@ async function main() {
1213
1341
  }
1214
1342
  assert(command === "build", "CLI_COMMAND", `Unknown command: ${command}`);
1215
1343
  const subcommand = args[0] && !args[0].startsWith("--") ? args.shift() : "create";
1216
- 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);
1217
1347
  const skipUpload = args.includes("--no-upload");
1218
1348
  await requireOperationalConfiguration(platform, { requireR2: !skipUpload });
1219
1349
  if (subcommand === "list") {
@@ -1237,90 +1367,114 @@ async function main() {
1237
1367
  printValue(job);
1238
1368
  return;
1239
1369
  }
1240
- assert(subcommand === "create", "CLI_BUILD_COMMAND", `Unknown build command: ${subcommand}`);
1370
+ assert(subcommand === "create" || buildAll, "CLI_BUILD_COMMAND", `Unknown build command: ${subcommand}`);
1241
1371
  const config = await loadConfig(root);
1242
1372
  const profile = flag("--profile", "production");
1243
- await requireAutolinkReady(root, platform);
1244
- const runtime = await inspectRuntimeFingerprint(root, platform, config);
1245
- const job = await builds.create({
1246
- projectId: configuredProjectId(config),
1247
- organizationId: "local_org",
1248
- platform,
1249
- profile,
1250
- sourceHash: createHash("sha256").update(root).digest("hex"),
1251
- runtimeVersion: runtime.value,
1252
- runtimeInputs: runtime.inputs,
1253
- });
1254
- ui.info(`Using profile: ${profile} · platform: ${platform}`);
1255
- const progress = ui.progress("Build execution");
1256
- try {
1257
- progress.update(undefined, "Preparing build pipeline…");
1258
- if (!args.includes("--no-wait")) {
1259
- if (platform === "android" &&
1260
- !isSupportedAndroidPlatform() &&
1261
- !args.includes("--local"))
1262
- assert(false, "ANDROID_PLATFORM_UNSUPPORTED", "Android builds are supported only on Linux, macOS and Windows.");
1263
- const realAndroid = platform === "android" &&
1264
- isSupportedAndroidPlatform() &&
1265
- (await hasAndroidHost(root));
1266
- const realIos = platform === "ios" && hasIosHost(root, config.build?.[profile]);
1267
- if (platform === "ios" && !realIos && !args.includes("--local"))
1268
- assert(false, "IOS_HOST_REQUIRED", "A macOS Xcode host is required for a real iOS build. No local fake iOS build is created.");
1269
- if (platform === "android" && !realAndroid && !args.includes("--local"))
1270
- 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.");
1271
- if (realAndroid) {
1272
- await runRealAndroidBuild(job, {
1273
- root,
1274
- profile: config.build?.[profile] ?? {},
1275
- uploadArtifacts: !skipUpload,
1276
- quiet: json,
1277
- onEvent: (message) => progress.event(message),
1278
- onProgress: (value, label) => progress.update(value, label),
1279
- });
1280
- }
1281
- else if (realIos) {
1282
- await runRealIosBuild(job, {
1283
- root,
1284
- profile: config.build?.[profile] ?? {},
1285
- uploadArtifacts: !skipUpload,
1286
- quiet: json,
1287
- onEvent: (message) => progress.event(message),
1288
- onProgress: (value, label) => progress.update(value, label),
1289
- });
1290
- }
1291
- else {
1292
- await builds.run(job.id);
1293
- }
1294
- }
1295
- progress.update(100);
1296
- await saveState(state, repository, builds, submissions);
1297
- }
1298
- catch (error) {
1299
- await saveState(state, repository, builds, submissions);
1300
- throw error;
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`.");
1301
1380
  }
1302
- finally {
1303
- 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;
1304
1395
  }
1305
- const result = builds.get(job.id);
1306
- printValue(result, {
1307
- title: "Build result",
1308
- rows: [
1309
- { label: "Build ID", value: result.id, valueColor: "purple" },
1310
- { label: "Platform", value: result.platform, valueColor: "blue" },
1311
- { label: "Profile", value: result.profile, valueColor: "text" },
1312
- {
1313
- label: "Status",
1314
- value: result.state,
1315
- valueColor: result.state === "success" ? "green" : "yellow",
1316
- },
1317
- ],
1318
- done: result.state === "success"
1319
- ? "Build complete. Run lynxship submit to publish."
1320
- : "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.",
1321
1470
  });
1322
- if (result.state === "success" && result.artifact?.url)
1323
- 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;
1324
1478
  }
1325
1479
  function exitCode(error) {
1326
1480
  const code = error.code;
@@ -1341,14 +1495,23 @@ function exitCode(error) {
1341
1495
  void main()
1342
1496
  .catch((error) => {
1343
1497
  const message = error instanceof Error ? error.message : "Unknown error";
1498
+ const code = error.code ?? "CLI_ERROR";
1499
+ const nextSteps = guidanceForError(error);
1344
1500
  if (json) {
1345
1501
  console.log(JSON.stringify({
1346
1502
  error: message,
1347
- 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
+ : {}),
1348
1510
  }));
1349
1511
  }
1350
1512
  else {
1351
1513
  ui.error(message);
1514
+ ui.nextSteps(nextSteps);
1352
1515
  }
1353
1516
  process.exitCode = exitCode(error);
1354
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;AAsDD,wBAAsB,eAAe,CACnC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,QAAQ,CAAC,CAqMnB"}
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"}
package/dist/ios-build.js CHANGED
@@ -5,7 +5,8 @@ import { assert, sha256 } from "@lynxship/contracts";
5
5
  import { transitionBuild } from "@lynxship/build-orchestrator";
6
6
  import { loadR2, uploadR2Artifact } from "./r2.js";
7
7
  import { nativeArtifactName } from "./artifact-name.js";
8
- import { commandExists, packageManagerScriptCommand, runProcess, } from "./process-runner.js";
8
+ import { commandExists, runProcess } from "./process-runner.js";
9
+ import { buildLynxBundle } from "./bundle-build.js";
9
10
  export function hasIosHost(root, profile) {
10
11
  if (process.platform !== "darwin")
11
12
  return false;
@@ -96,13 +97,16 @@ export async function runRealIosBuild(job, options) {
96
97
  await loadR2(options.root);
97
98
  await mkdir(archiveDirectory, { recursive: true });
98
99
  transitionBuild(job, "uploading_source", "iOS source prepared");
99
- step("Building Lynx bundle with Rspeedy…", 5);
100
- const packageManager = packageManagerScriptCommand(options.root, "build");
101
- await runProcess(packageManager.command, packageManager.args, {
102
- cwd: options.root,
103
- quiet: options.quiet,
104
- onOutput: options.onEvent,
105
- });
100
+ if (options.skipBundleBuild) {
101
+ step("Using shared Lynx bundle", 5);
102
+ }
103
+ else {
104
+ step("Building Lynx bundle with Rspeedy…", 5);
105
+ await buildLynxBundle(options.root, {
106
+ quiet: options.quiet,
107
+ onOutput: options.onEvent,
108
+ });
109
+ }
106
110
  if (ios.bundleScript) {
107
111
  step("Syncing bundle into the iOS host…", 8);
108
112
  await runProcess(process.execPath, [resolve(options.root, ios.bundleScript)], {
@@ -18,6 +18,7 @@ export interface ProgressHandle {
18
18
  update(value?: number, label?: string): void;
19
19
  stop(): void;
20
20
  }
21
+ export declare function formatEvent(message: string): string;
21
22
  export declare function createProgress(label: string, enabled: boolean): ProgressHandle;
22
23
  export interface SpinnerHandle {
23
24
  succeed(message: string): void;
@@ -32,5 +33,6 @@ export declare const pill: {
32
33
  error: (text: string) => string;
33
34
  };
34
35
  export declare function finalLine(message: string, success?: boolean): void;
36
+ export declare function nextSteps(commands: string[], note?: string): void;
35
37
  export declare function downloadArtifact(url: string, expiresAt?: string, enabled?: boolean): void;
36
38
  //# sourceMappingURL=components.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../../src/ui/components.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAI7C,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,SAAS,CAAC;CACxB;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEjD;AAED,eAAO,MAAM,GAAG;qBACG,MAAM,GAAG,IAAI;kBAGhB,MAAM,GAAG,IAAI;mBAGZ,MAAM,GAAG,IAAI;kBAGd,MAAM,GAAG,IAAI;mBAGZ,MAAM,GAAG,IAAI;CAG7B,CAAC;AAgBF,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CA6C9D;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,MAAM,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,IAAI,IAAI,IAAI,CAAC;CACd;AA2FD,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,OAAO,GACf,cAAc,CA8DhB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,IAAI,IAAI,IAAI,CAAC;CACd;AAED,wBAAgB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,aAAa,CA2BlE;AAYD,eAAO,MAAM,IAAI;oBACC,MAAM;iBACT,MAAM;iBACN,MAAM;kBACL,MAAM;CACrB,CAAC;AAEF,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,UAAO,GAAG,IAAI,CAE/D;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,UAAO,GACb,IAAI,CAQN"}
1
+ {"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../../src/ui/components.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAI7C,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,SAAS,CAAC;CACxB;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEjD;AAED,eAAO,MAAM,GAAG;qBACG,MAAM,GAAG,IAAI;kBAGhB,MAAM,GAAG,IAAI;mBAGZ,MAAM,GAAG,IAAI;kBAGd,MAAM,GAAG,IAAI;mBAGZ,MAAM,GAAG,IAAI;CAG7B,CAAC;AAgBF,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,CA6C9D;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,MAAM,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,IAAI,IAAI,IAAI,CAAC;CACd;AA6CD,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAoBnD;AAyBD,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,OAAO,GACf,cAAc,CAkEhB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,IAAI,IAAI,IAAI,CAAC;CACd;AAED,wBAAgB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,aAAa,CA2BlE;AAYD,eAAO,MAAM,IAAI;oBACC,MAAM;iBACT,MAAM;iBACN,MAAM;kBACL,MAAM;CACrB,CAAC;AAEF,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,UAAO,GAAG,IAAI,CAE/D;AAED,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAOjE;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,UAAO,GACb,IAAI,CAQN"}
@@ -10,13 +10,13 @@ export function sectionHeader(title) {
10
10
  }
11
11
  export const log = {
12
12
  success(message) {
13
- console.log(` ${c.teal("[OK] ")} ${c.text(message)}`);
13
+ console.log(` ${c.green("")} ${c.text(message)}`);
14
14
  },
15
15
  warn(message) {
16
- console.log(` ${c.yellow("[WARN]")} ${c.yellow(message)}`);
16
+ console.log(` ${c.yellow("!")} ${c.yellow(message)}`);
17
17
  },
18
18
  error(message) {
19
- console.error(` ${c.red("[ERROR]")} ${c.red(message)}`);
19
+ console.error(` ${c.red("x")} ${c.red(message)}`);
20
20
  },
21
21
  info(message) {
22
22
  console.log(` ${c.muted(message)}`);
@@ -105,7 +105,7 @@ function eventTone(message) {
105
105
  return "step";
106
106
  return "output";
107
107
  }
108
- function formatEvent(message) {
108
+ export function formatEvent(message) {
109
109
  const colors = {
110
110
  command: c.blue,
111
111
  step: c.teal,
@@ -115,17 +115,16 @@ function formatEvent(message) {
115
115
  output: c.text,
116
116
  };
117
117
  const tone = eventTone(message);
118
- const tag = {
119
- command: "[CMD]",
120
- step: "[STEP]",
121
- success: "[OK]",
122
- warning: "[WARN]",
123
- error: "[ERROR]",
124
- output: "[LOG]",
125
- }[tone];
126
118
  const normalized = message.trim().replace(/^\$\s*/, "");
127
- const rail = supportsUnicode() ? "│" : "|";
128
- return ` ${colors[tone](rail)} ${colors[tone](tag.padEnd(7))} ${colors[tone](normalized)}`;
119
+ const marker = {
120
+ command: supportsUnicode() ? "➜" : ">",
121
+ step: "-",
122
+ success: supportsUnicode() ? "◆" : "*",
123
+ warning: "!",
124
+ error: "x",
125
+ output: supportsUnicode() ? "│" : "|",
126
+ }[tone];
127
+ return ` ${colors[tone](marker)} ${colors[tone](normalized)}`;
129
128
  }
130
129
  const activeAnimations = new Set();
131
130
  let cleanupHooksInstalled = false;
@@ -162,6 +161,7 @@ export function createProgress(label, enabled) {
162
161
  let spinnerIndex = 0;
163
162
  let currentValue = 0;
164
163
  let hasMeasuredValue = false;
164
+ let previousTone;
165
165
  const formatPercent = (value) => `${value.toFixed(2).replace(/\.?0+$/, "")}%`;
166
166
  const bar = new cliProgress.SingleBar({
167
167
  format: ` {spinner} {stage} ${c.teal("{bar}")} ${c.muted("{percent}")}`,
@@ -191,9 +191,13 @@ export function createProgress(label, enabled) {
191
191
  const logEvent = (message) => {
192
192
  if (!bar.isActive)
193
193
  return;
194
+ const tone = eventTone(message);
194
195
  bar.stop();
196
+ if (tone === "output" && previousTone === "output")
197
+ console.log();
195
198
  console.log(formatEvent(message));
196
199
  bar.start(100, currentValue, payload(currentLabel));
200
+ previousTone = tone;
197
201
  };
198
202
  return {
199
203
  event: logEvent,
@@ -228,11 +232,11 @@ export function spin(text, enabled) {
228
232
  const untrack = trackAnimation(() => spinner.stop());
229
233
  return {
230
234
  succeed: (message) => {
231
- spinner.succeed(`${c.teal("[OK]")} ${c.text(message)}`);
235
+ spinner.succeed(`${c.green("")} ${c.text(message)}`);
232
236
  untrack();
233
237
  },
234
238
  fail: (message) => {
235
- spinner.fail(`${c.red("[ERROR]")} ${c.red(message)}`);
239
+ spinner.fail(`${c.red("x")} ${c.red(message)}`);
236
240
  untrack();
237
241
  },
238
242
  stop: () => {
@@ -255,6 +259,16 @@ export const pill = {
255
259
  export function finalLine(message, success = true) {
256
260
  console.log(`\n${success ? c.brand("◆") : c.red("◆")} ${c.text(message)}`);
257
261
  }
262
+ export function nextSteps(commands, note) {
263
+ if (commands.length === 0)
264
+ return;
265
+ console.error(`\n${c.yellow("Next steps")}`);
266
+ commands.forEach((command, index) => {
267
+ console.error(` ${c.muted(`${index + 1}.`)} ${c.teal(command)}`);
268
+ });
269
+ if (note)
270
+ console.error(` ${c.muted(note)}`);
271
+ }
258
272
  export function downloadArtifact(url, expiresAt, enabled = true) {
259
273
  if (!enabled)
260
274
  return;
@@ -1,5 +1,6 @@
1
1
  import { type BoxRow, type ProgressHandle, type SpinnerHandle } from "./components.js";
2
2
  import { terminalOptions, type TerminalOptions } from "./terminal.js";
3
+ import type { CliGuidance } from "../guidance.js";
3
4
  export declare class CliUi {
4
5
  readonly options: TerminalOptions;
5
6
  readonly interactive: boolean;
@@ -11,6 +12,7 @@ export declare class CliUi {
11
12
  info(message: string): void;
12
13
  debug(message: string): void;
13
14
  error(message: string): void;
15
+ nextSteps(guidance: CliGuidance): void;
14
16
  summary(title: string, rows: BoxRow[]): void;
15
17
  configurationStatus(rows: BoxRow[]): void;
16
18
  progress(label: string): ProgressHandle;
@@ -20,5 +22,5 @@ export declare class CliUi {
20
22
  }
21
23
  export declare function createCliUi(args: string[]): CliUi;
22
24
  export { terminalOptions };
23
- export type { BoxRow, TerminalOptions };
25
+ export type { BoxRow, ProgressHandle, TerminalOptions };
24
26
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,MAAM,EACX,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EAEL,eAAe,EACf,KAAK,eAAe,EACrB,MAAM,eAAe,CAAC;AAEvB,qBAAa,KAAK;IAChB,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAElC,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;gBAElB,IAAI,EAAE,MAAM,EAAE;IAU1B,MAAM,IAAI,IAAI;IAId,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAI3B,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAI9B,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAI3B,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAI3B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAK5B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAI5B,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI;IAI5C,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI;IAKzC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc;IAIvC,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa;IAIpC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,UAAO,GAAG,IAAI;IAI3C,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI;CAGxD;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,KAAK,CAEjD;AAED,OAAO,EAAE,eAAe,EAAE,CAAC;AAE3B,YAAY,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAAA,OAAO,EASL,KAAK,MAAM,EACX,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EAEL,eAAe,EACf,KAAK,eAAe,EACrB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAElD,qBAAa,KAAK;IAChB,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAElC,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;gBAElB,IAAI,EAAE,MAAM,EAAE;IAU1B,MAAM,IAAI,IAAI;IAId,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAI3B,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAI9B,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAI3B,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAI3B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAK5B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAI5B,SAAS,CAAC,QAAQ,EAAE,WAAW,GAAG,IAAI;IAKtC,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI;IAI5C,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI;IAKzC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc;IAIvC,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa;IAIpC,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,UAAO,GAAG,IAAI;IAI3C,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI;CAGxD;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,KAAK,CAEjD;AAED,OAAO,EAAE,eAAe,EAAE,CAAC;AAE3B,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,eAAe,EAAE,CAAC"}
package/dist/ui/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { finalLine, log, sectionHeader, summaryBox, createProgress, spin, downloadArtifact, } from "./components.js";
1
+ import { finalLine, log, sectionHeader, summaryBox, nextSteps, createProgress, spin, downloadArtifact, } from "./components.js";
2
2
  import { printBanner } from "./logo.js";
3
3
  import { setColors } from "./state.js";
4
4
  import { isInteractive, terminalOptions, } from "./terminal.js";
@@ -40,6 +40,11 @@ export class CliUi {
40
40
  if (!this.options.json)
41
41
  log.error(message);
42
42
  }
43
+ nextSteps(guidance) {
44
+ if (this.options.json || this.options.quiet)
45
+ return;
46
+ nextSteps(guidance.commands, guidance.note);
47
+ }
43
48
  summary(title, rows) {
44
49
  if (this.interactive)
45
50
  summaryBox(title, rows);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lynxship/cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Build, sign, store, submit and update LynxJS applications from the terminal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -70,9 +70,9 @@
70
70
  "qrcode-terminal": "0.12.0",
71
71
  "@lynxship/build-orchestrator": "0.1.0",
72
72
  "@lynxship/contracts": "0.1.0",
73
+ "@lynxship/signing": "0.1.0",
73
74
  "@lynxship/submit": "0.1.0",
74
- "@lynxship/db": "0.1.0",
75
- "@lynxship/signing": "0.1.0"
75
+ "@lynxship/db": "0.1.0"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@types/cli-progress": "3.11.6",