@lynxship/cli 0.1.3 → 0.1.6

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 (43) hide show
  1. package/README.md +63 -2
  2. package/dist/android-build.d.ts.map +1 -1
  3. package/dist/android-build.js +61 -2
  4. package/dist/android-host.d.ts +12 -0
  5. package/dist/android-host.d.ts.map +1 -0
  6. package/dist/android-host.js +70 -0
  7. package/dist/index.js +97 -4
  8. package/dist/ios-build.d.ts.map +1 -1
  9. package/dist/ios-build.js +26 -4
  10. package/dist/ios-host.d.ts +17 -0
  11. package/dist/ios-host.d.ts.map +1 -0
  12. package/dist/ios-host.js +93 -0
  13. package/package.json +6 -5
  14. package/templates/android-host/app/build.gradle +38 -0
  15. package/templates/android-host/app/proguard-rules.pro +2 -0
  16. package/templates/android-host/app/src/main/AndroidManifest.xml +20 -0
  17. package/templates/android-host/app/src/main/java/template/LynxShipApplication.java +13 -0
  18. package/templates/android-host/app/src/main/java/template/MainActivity.java +19 -0
  19. package/templates/android-host/app/src/main/java/template/ProjectTemplateProvider.java +32 -0
  20. package/templates/android-host/app/src/main/res/values/strings.xml +3 -0
  21. package/templates/android-host/app/src/main/res/values/themes.xml +6 -0
  22. package/templates/android-host/build.gradle +3 -0
  23. package/templates/android-host/gradle/wrapper/gradle-wrapper.jar +0 -0
  24. package/templates/android-host/gradle/wrapper/gradle-wrapper.properties +7 -0
  25. package/templates/android-host/gradle.properties +3 -0
  26. package/templates/android-host/gradlew +185 -0
  27. package/templates/android-host/gradlew.bat +89 -0
  28. package/templates/android-host/settings.gradle +20 -0
  29. package/templates/ios-host/ExportOptions.plist +16 -0
  30. package/templates/ios-host/Podfile +42 -0
  31. package/templates/ios-host/__IOS_TARGET_NAME__/AppDelegate.swift +11 -0
  32. package/templates/ios-host/__IOS_TARGET_NAME__/Assets.xcassets/AccentColor.colorset/Contents.json +11 -0
  33. package/templates/ios-host/__IOS_TARGET_NAME__/Assets.xcassets/AppIcon.appiconset/Contents.json +13 -0
  34. package/templates/ios-host/__IOS_TARGET_NAME__/Assets.xcassets/Contents.json +6 -0
  35. package/templates/ios-host/__IOS_TARGET_NAME__/Base.lproj/LaunchScreen.storyboard +25 -0
  36. package/templates/ios-host/__IOS_TARGET_NAME__/Base.lproj/Main.storyboard +24 -0
  37. package/templates/ios-host/__IOS_TARGET_NAME__/DemoLynxProvider.swift +18 -0
  38. package/templates/ios-host/__IOS_TARGET_NAME__/Hello-Lynx-Bridging-Header.h +4 -0
  39. package/templates/ios-host/__IOS_TARGET_NAME__/Info.plist +25 -0
  40. package/templates/ios-host/__IOS_TARGET_NAME__/SceneDelegate.swift +49 -0
  41. package/templates/ios-host/__IOS_TARGET_NAME__/ViewController.swift +22 -0
  42. package/templates/ios-host/__IOS_TARGET_NAME__.xcodeproj/project.pbxproj +452 -0
  43. package/templates/ios-host/sync-bundle.mjs +11 -0
package/README.md CHANGED
@@ -97,12 +97,27 @@ Progress percentages are shown only when LynxShip has a real measurement. Long
97
97
  Rspeedy, Gradle and Xcode operations remain visible in the event journal until
98
98
  their completion checkpoint is known; no timer-based percentage is invented.
99
99
 
100
+ ### Project-safe Android signing
101
+
102
+ LynxShip does not require every project to contain a LynxShip-specific signing
103
+ helper. During a real Android build it creates a temporary Gradle init script,
104
+ passes it with Gradle's `--init-script` option, and removes it when the build
105
+ finishes. The script reads only the already configured machine credentials and
106
+ uses the Android Components `finalizeDsl` hook to configure the standard
107
+ `release` build type. The project's Gradle files are left unchanged.
108
+
109
+ This supports normal Android Gradle Plugin application projects using either
110
+ Groovy or Kotlin DSL. Projects with custom flavor tasks, non-standard signing
111
+ plugins, or no Android `release` build type must expose their signing contract
112
+ explicitly; LynxShip fails with a clear diagnostic rather than producing an
113
+ unverified artifact.
114
+
100
115
  ## Main commands
101
116
 
102
117
  ```text
103
118
  init Initialize or link a project
104
119
  doctor Check the local toolchain and project
105
- dev Run the Rspeedy development server
120
+ dev Run Rspeedy dev with Lynx Explorer QR/HMR
106
121
  preview Preview the production bundle locally
107
122
  build create Build, sign and upload an artifact
108
123
  build list List build jobs
@@ -118,7 +133,8 @@ logs Stream native logs
118
133
  autolink check Check Lynx native-library wiring
119
134
  autolink codegen Run native-module codegen
120
135
  ota doctor Check native OTA host integration
121
- storage configure Configure Cloudflare R2
136
+ storage configure Configure Cloudflare R2
137
+ android host init Create a minimal official Lynx Android host
122
138
  android configure Configure Android signing
123
139
  store configure Configure store submission credentials
124
140
  ```
@@ -155,6 +171,51 @@ command-line tools with `sdkmanager`, accept the required licenses, set
155
171
  `android/gradlew` is executable. macOS can build both Android and iOS; Windows
156
172
  and Linux can build Android only.
157
173
 
174
+ ## Pure Lynx projects and Lynx Explorer
175
+
176
+ A standard Rspeedy project can be developed without a native Android host:
177
+
178
+ ```bash
179
+ lynxship dev --project-dir ./my-lynx-app
180
+ ```
181
+
182
+ Rspeedy serves the development bundle and prints the QR code. Scan it with the
183
+ official Lynx Explorer app; edits to the Lynx source are then reflected live.
184
+ This is the supported path for projects containing `src/` and `lynx.config.*`
185
+ but no `android/` directory.
186
+
187
+ Production APK/AAB builds require a native Android host. The host is the
188
+ Android application that initializes Lynx, creates `LynxView`, loads the bundle
189
+ and contains the Gradle wrapper. `lynxship build` detects that requirement and
190
+ fails clearly when `android/gradlew` is absent; `--local` only tests LynxShip's
191
+ contract state machine and never fabricates an APK.
192
+
193
+ To create a minimal host for a pure project:
194
+
195
+ ```bash
196
+ lynxship android host init --application-id com.example.myapp
197
+ ```
198
+
199
+ This command never overwrites an existing `android/` directory. It creates a
200
+ real Gradle application with the official Lynx Android dependencies,
201
+ `LynxEnv`, `LynxView`, a bundle loader and a Gradle wrapper. Replace the example
202
+ application ID before a store release and add any project-specific native
203
+ modules, permissions, services and OTA integration explicitly.
204
+
205
+ For a pure project that targets iOS, create the native Xcode/CocoaPods host
206
+ with:
207
+
208
+ ```bash
209
+ lynxship ios host init --bundle-identifier com.example.myapp
210
+ ```
211
+
212
+ The command refuses to overwrite an existing `ios/` directory and creates a
213
+ Swift host based on Lynx's official integration shape: `LynxEnv`, `LynxView`,
214
+ `LynxTemplateProvider`, `Podfile`, `ExportOptions.plist` and a bundle sync
215
+ script. On macOS, `lynxship build --platform ios` installs CocoaPods before
216
+ archiving. Xcode, CocoaPods and real Apple signing credentials are still
217
+ required for a signed IPA; the CLI never fabricates them.
218
+
158
219
  ## Package layout
159
220
 
160
221
  The CLI is backed by the public `@lynxship/*` runtime packages in this
@@ -1 +1 @@
1
- {"version":3,"file":"android-build.d.ts","sourceRoot":"","sources":["../src/android-build.ts"],"names":[],"mappings":"AAWA,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;AAoID,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,CAyJnB"}
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,6 +1,7 @@
1
- import { access, copyFile, cp, mkdir, readFile, readdir, } from "node:fs/promises";
1
+ import { access, chmod, copyFile, cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile, } from "node:fs/promises";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { constants as fsConstants, existsSync, readdirSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
5
6
  import { assert, sha256 } from "@lynxship/contracts";
6
7
  import { transitionBuild } from "@lynxship/build-orchestrator";
@@ -33,6 +34,56 @@ async function signingEnvironment(root) {
33
34
  assert(missing.length === 0, "BUILD_SIGNING_REQUIRED", `Signed Android builds require configuration. Missing: ${missing.join(", ")}. Run \`lynxship android configure\`.`);
34
35
  return { ...process.env, ...values };
35
36
  }
37
+ async function createSigningInitScript() {
38
+ const directory = await mkdtemp(join(tmpdir(), "lynxship-gradle-"));
39
+ const file = join(directory, "android-signing.init.gradle");
40
+ const script = `
41
+ def keystorePath = System.getenv("LYNXSHIP_KEYSTORE_PATH")
42
+ def keyAlias = System.getenv("LYNXSHIP_KEY_ALIAS")
43
+ def keystorePassword = System.getenv("LYNXSHIP_KEYSTORE_PASSWORD")
44
+ def keyPassword = System.getenv("LYNXSHIP_KEY_PASSWORD")
45
+
46
+ gradle.allprojects { project ->
47
+ project.plugins.withId("com.android.application") {
48
+ def configureSigning = { android ->
49
+ def signing = android.signingConfigs.findByName("release")
50
+ if (signing == null) {
51
+ signing = android.signingConfigs.create("lynxshipRelease")
52
+ }
53
+ signing.storeFile = project.file(keystorePath)
54
+ signing.storePassword = keystorePassword
55
+ signing.keyAlias = keyAlias
56
+ signing.keyPassword = keyPassword
57
+
58
+ def release = android.buildTypes.findByName("release")
59
+ if (release == null) {
60
+ throw new GradleException(
61
+ "LynxShip requires an Android release build type"
62
+ )
63
+ }
64
+ release.signingConfig = signing
65
+ project.logger.lifecycle(
66
+ "[LynxShip] Applied machine signing credentials to \${project.path}:release"
67
+ )
68
+ }
69
+
70
+ def androidComponents = project.extensions.findByName("androidComponents")
71
+ if (androidComponents != null) {
72
+ androidComponents.finalizeDsl { android ->
73
+ configureSigning(android)
74
+ }
75
+ } else {
76
+ project.afterEvaluate {
77
+ configureSigning(project.extensions.findByName("android"))
78
+ }
79
+ }
80
+ }
81
+ }
82
+ `;
83
+ await writeFile(file, script, "utf8");
84
+ await chmod(file, 0o600).catch(() => undefined);
85
+ return { directory, file };
86
+ }
36
87
  async function verifySignedArtifact(root, artifactPath, options) {
37
88
  if (artifactPath.endsWith(".apk")) {
38
89
  const apksigner = androidTool("apksigner");
@@ -161,6 +212,7 @@ export async function runRealAndroidBuild(job, options) {
161
212
  quiet: options.quiet ?? false,
162
213
  onOutput: options.onEvent,
163
214
  };
215
+ let signingInitDirectory;
164
216
  try {
165
217
  transitionBuild(job, "uploading_source", "local Android source prepared");
166
218
  job.logs.push({
@@ -189,7 +241,10 @@ export async function runRealAndroidBuild(job, options) {
189
241
  step("Android host synchronized", 40);
190
242
  transitionBuild(job, "building", `Gradle ${artifact.task}`);
191
243
  step(`Running real Gradle task ${artifact.task}…`);
192
- const gradleArgs = [artifact.task];
244
+ step("Applying temporary LynxShip Android signing adapter…");
245
+ const signingInitScript = await createSigningInitScript();
246
+ signingInitDirectory = signingInitScript.directory;
247
+ const gradleArgs = ["--init-script", signingInitScript.file, artifact.task];
193
248
  if (process.env.CI)
194
249
  gradleArgs.push("--stacktrace");
195
250
  await runProcess(wrapper, gradleArgs, {
@@ -258,4 +313,8 @@ export async function runRealAndroidBuild(job, options) {
258
313
  });
259
314
  throw error;
260
315
  }
316
+ finally {
317
+ if (signingInitDirectory)
318
+ await rm(signingInitDirectory, { recursive: true, force: true });
319
+ }
261
320
  }
@@ -0,0 +1,12 @@
1
+ export interface AndroidHostOptions {
2
+ applicationId: string;
3
+ appName: string;
4
+ }
5
+ export interface AndroidHostResult {
6
+ directory: string;
7
+ applicationId: string;
8
+ packageName: string;
9
+ }
10
+ export declare function initializeAndroidHost(root: string, options: AndroidHostOptions): Promise<AndroidHostResult>;
11
+ export declare function suggestedAndroidApplicationId(root: string): string;
12
+ //# sourceMappingURL=android-host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"android-host.d.ts","sourceRoot":"","sources":["../src/android-host.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,kBAAkB;IACjC,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;CACrB;AA2BD,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,iBAAiB,CAAC,CA6D5B;AAED,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKlE"}
@@ -0,0 +1,70 @@
1
+ import { access, chmod, cp, mkdir, readFile, rename, writeFile, } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { assert } from "@lynxship/contracts";
5
+ const templateRoot = fileURLToPath(new URL("../templates/android-host/", import.meta.url));
6
+ function packagePath(packageName) {
7
+ return packageName.split(".").join("/");
8
+ }
9
+ function validateApplicationId(applicationId) {
10
+ assert(/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(applicationId), "ANDROID_APPLICATION_ID_INVALID", "Android application ID must contain at least two dot-separated Java package segments, for example com.example.myapp.");
11
+ }
12
+ async function exists(path) {
13
+ try {
14
+ await access(path);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ function safeAppName(value) {
22
+ return value.replace(/[^a-zA-Z0-9 ._-]/g, "").trim() || "Lynx App";
23
+ }
24
+ export async function initializeAndroidHost(root, options) {
25
+ validateApplicationId(options.applicationId);
26
+ const android = join(root, "android");
27
+ assert(!(await exists(android)), "ANDROID_HOST_EXISTS", `The Android host already exists at ${android}. LynxShip will not overwrite it.`);
28
+ const packageName = options.applicationId;
29
+ const packageDirectory = join(android, "app", "src", "main", "java", packagePath(packageName));
30
+ await cp(templateRoot, android, { recursive: true, force: false });
31
+ await mkdir(dirname(packageDirectory), { recursive: true });
32
+ await rename(join(android, "app", "src", "main", "java", "template"), packageDirectory);
33
+ const replacements = {
34
+ __APPLICATION_ID__: options.applicationId,
35
+ __PACKAGE_NAME__: packageName,
36
+ __APP_NAME__: safeAppName(options.appName),
37
+ };
38
+ const textFiles = [
39
+ "build.gradle",
40
+ "settings.gradle",
41
+ "gradle.properties",
42
+ "app/build.gradle",
43
+ "app/src/main/AndroidManifest.xml",
44
+ "app/src/main/res/values/strings.xml",
45
+ "app/src/main/res/values/themes.xml",
46
+ "app/src/main/java/__PACKAGE_PATH__/LynxShipApplication.java",
47
+ "app/src/main/java/__PACKAGE_PATH__/MainActivity.java",
48
+ "app/src/main/java/__PACKAGE_PATH__/ProjectTemplateProvider.java",
49
+ ];
50
+ for (const relativeFile of textFiles) {
51
+ const target = join(android, relativeFile.replace("__PACKAGE_PATH__", packagePath(packageName)));
52
+ let content = await readFile(target, "utf8");
53
+ for (const [placeholder, value] of Object.entries(replacements))
54
+ content = content.replaceAll(placeholder, value);
55
+ await writeFile(target, content, "utf8");
56
+ }
57
+ if (process.platform !== "win32")
58
+ await chmod(join(android, "gradlew"), 0o755);
59
+ return {
60
+ directory: android,
61
+ applicationId: options.applicationId,
62
+ packageName,
63
+ };
64
+ }
65
+ export function suggestedAndroidApplicationId(root) {
66
+ const project = basename(root)
67
+ .toLowerCase()
68
+ .replace(/[^a-z0-9]+/g, "");
69
+ return `com.example.${project || "lynxapp"}`;
70
+ }
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
- import { dirname, join, resolve } from "node:path";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
6
  import { BuildOrchestrator } from "@lynxship/build-orchestrator";
7
7
  import { JsonRepository } from "@lynxship/db";
8
8
  import { assert, createId, sha256, } from "@lynxship/contracts";
@@ -10,6 +10,8 @@ import { createSigningKey, signManifest, } from "@lynxship/signing";
10
10
  import { AppStoreConnectApiProvider, GooglePlayApiProvider, SubmissionService, } from "@lynxship/submit";
11
11
  import { DEFAULT_CONFIG, loadConfig, platformValue, } from "./config.js";
12
12
  import { hasAndroidHost, isSupportedAndroidPlatform, runRealAndroidBuild, } from "./android-build.js";
13
+ import { initializeAndroidHost, suggestedAndroidApplicationId, } from "./android-host.js";
14
+ import { initializeIosHost, suggestedIosBundleIdentifier } from "./ios-host.js";
13
15
  import { hasIosHost, runRealIosBuild } from "./ios-build.js";
14
16
  import { configureAndroid, configureAppStoreConnect, configureGooglePlay, configureR2, } from "./configure.js";
15
17
  import { fetchOtaPublicKey, publishOtaRelease, rollbackOtaRelease, submitRealArtifact, } from "./remote.js";
@@ -21,6 +23,7 @@ import { inspectAutolink, requireAutolinkReady } from "./autolink.js";
21
23
  import { assertCompatibleBinaryBuild, inspectRuntimeFingerprint, } from "./runtime-fingerprint.js";
22
24
  import { inspectOtaHost } from "./ota-doctor.js";
23
25
  import { otaAssetName, otaAssetPaths } from "./ota-assets.js";
26
+ import { prompt } from "./prompt.js";
24
27
  import { commandExists, packageManagerCommand, runProcess, runRspeedy, } from "./process-runner.js";
25
28
  const rawArgs = process.argv.slice(2);
26
29
  const args = [...rawArgs];
@@ -52,6 +55,10 @@ function flag(name, fallback = null) {
52
55
  const index = args.indexOf(name);
53
56
  return index >= 0 ? (args[index + 1] ?? "true") : fallback;
54
57
  }
58
+ async function assertInteractivePrompt(label, fallback, optionName) {
59
+ assert(ui.interactive, "CLI_INTERACTIVE_REQUIRED", `Pass ${label.toLowerCase()} with ${optionName} in non-interactive mode.`);
60
+ return prompt(label, fallback);
61
+ }
55
62
  function printValue(value, view) {
56
63
  if (json) {
57
64
  console.log(JSON.stringify(typeof value === "string" ? { result: value } : value));
@@ -218,7 +225,7 @@ function helpText() {
218
225
  Commands:
219
226
  init Initialize or link a LynxShip project
220
227
  doctor Check the local toolchain and project
221
- dev Run the project's Rspeedy development server
228
+ dev Run Rspeedy dev with Lynx Explorer QR/HMR
222
229
  preview Preview the production Lynx bundle locally
223
230
  inspect Inspect Rspeedy/Rspack configuration
224
231
  profile Build with Rspack profiling enabled
@@ -238,6 +245,8 @@ Commands:
238
245
  rollback Alias for update rollback
239
246
  self-host init Generate local self-host credentials
240
247
  storage configure Configure Cloudflare R2 and encrypted R2 credentials
248
+ android host init Create a minimal official Lynx Android host
249
+ ios host init Create a minimal official Lynx iOS/Xcode host
241
250
  android configure Configure or generate encrypted Android signing credentials
242
251
  store configure Configure Google Play or App Store Connect submission
243
252
 
@@ -286,6 +295,8 @@ Global options:
286
295
  --banner Show the Braille LynxShip logo in a TTY
287
296
  --project-dir <path> Use a LynxShip project from any working directory
288
297
  --project-id <id> Project ID used by init
298
+ --application-id <id> Android package/application ID for host init
299
+ --bundle-identifier <id> iOS bundle identifier for host init
289
300
  --library-dir <path> Native library directory for autolink codegen
290
301
  --simulator Install an iOS .app on a simulator with simctl
291
302
  --help Show this complete command reference
@@ -365,6 +376,9 @@ async function runRspeedyCommand(subcommand, environment) {
365
376
  await initializeBuildProject();
366
377
  const forwarded = forwardedToolArgs(args);
367
378
  ui.info(`Running local Rspeedy ${subcommand}…`);
379
+ if (subcommand === "dev") {
380
+ ui.info("Lynx Explorer mode: no Android or iOS native host is required. Scan the QR code printed by Rspeedy; source changes reload automatically.");
381
+ }
368
382
  await runRspeedy(root, subcommand, forwarded, {
369
383
  env: environment,
370
384
  quiet: json,
@@ -780,9 +794,88 @@ async function main() {
780
794
  });
781
795
  return;
782
796
  }
797
+ if (command === "ios") {
798
+ const subcommand = args.shift() ?? "host";
799
+ assert(subcommand === "host" && (args.shift() ?? "init") === "init", "CLI_IOS_HOST_COMMAND", "Use `lynxship ios host init` to create an iOS host.");
800
+ await initializeBuildProject();
801
+ const suggestedId = suggestedIosBundleIdentifier(root);
802
+ const bundleIdentifier = flag("--bundle-identifier") ??
803
+ (await assertInteractivePrompt("iOS bundle identifier", suggestedId, "--bundle-identifier"));
804
+ const result = await initializeIosHost(root, {
805
+ bundleIdentifier,
806
+ appName: basename(root),
807
+ });
808
+ ui.success(`iOS host created: ${result.directory}`);
809
+ printValue({
810
+ status: "created",
811
+ platform: "ios",
812
+ directory: result.directory,
813
+ bundleIdentifier: result.bundleIdentifier,
814
+ project: result.project,
815
+ scheme: result.scheme,
816
+ configUpdated: result.configUpdated,
817
+ }, {
818
+ title: "iOS host",
819
+ rows: [
820
+ {
821
+ label: "Bundle identifier",
822
+ value: result.bundleIdentifier,
823
+ valueColor: "blue",
824
+ },
825
+ {
826
+ label: "Xcode project",
827
+ value: result.project,
828
+ valueColor: "green",
829
+ },
830
+ { label: "Scheme", value: result.scheme, valueColor: "purple" },
831
+ {
832
+ label: "CocoaPods",
833
+ value: "Run pod install on macOS before the first build",
834
+ valueColor: "yellow",
835
+ },
836
+ ],
837
+ done: "iOS host is ready. Run lynxship doctor --platform ios on macOS.",
838
+ });
839
+ return;
840
+ }
783
841
  if (command === "android") {
842
+ const subcommand = args.shift() ?? "configure";
843
+ if (subcommand === "host") {
844
+ assert((args.shift() ?? "init") === "init", "CLI_ANDROID_HOST_COMMAND", "Use `lynxship android host init` to create an Android host.");
845
+ await initializeBuildProject();
846
+ const suggestedId = suggestedAndroidApplicationId(root);
847
+ const applicationId = flag("--application-id") ??
848
+ (await assertInteractivePrompt("Android application ID", suggestedId, "--application-id"));
849
+ const result = await initializeAndroidHost(root, {
850
+ applicationId,
851
+ appName: basename(root),
852
+ });
853
+ ui.success(`Android host created: ${result.directory}`);
854
+ printValue({
855
+ status: "created",
856
+ platform: "android",
857
+ directory: result.directory,
858
+ applicationId: result.applicationId,
859
+ }, {
860
+ title: "Android host",
861
+ rows: [
862
+ {
863
+ label: "Application ID",
864
+ value: result.applicationId,
865
+ valueColor: "blue",
866
+ },
867
+ {
868
+ label: "Gradle wrapper",
869
+ value: join(result.directory, "gradlew"),
870
+ valueColor: "green",
871
+ },
872
+ ],
873
+ done: "Android host is ready. Run lynxship doctor, then lynxship build.",
874
+ });
875
+ return;
876
+ }
784
877
  assert(ui.interactive, "CLI_INTERACTIVE_REQUIRED", "Run `lynxship android configure` in an interactive terminal");
785
- assert((args.shift() ?? "configure") === "configure", "CLI_ANDROID_COMMAND", "Only android configure is available");
878
+ assert(subcommand === "configure", "CLI_ANDROID_COMMAND", "Only android configure is available");
786
879
  ui.info("Configuring Android signing. Secret fields will stay invisible…");
787
880
  const result = await configureAndroid(root);
788
881
  ui.success(result.generated
@@ -1174,7 +1267,7 @@ async function main() {
1174
1267
  if (platform === "ios" && !realIos && !args.includes("--local"))
1175
1268
  assert(false, "IOS_HOST_REQUIRED", "A macOS Xcode host is required for a real iOS build. No local fake iOS build is created.");
1176
1269
  if (platform === "android" && !realAndroid && !args.includes("--local"))
1177
- assert(false, "ANDROID_HOST_REQUIRED", "A real Android build requires an Android Gradle host. Add android/gradlew and the native Lynx host, or use --local for contract tests.");
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.");
1178
1271
  if (realAndroid) {
1179
1272
  await runRealAndroidBuild(job, {
1180
1273
  root,
@@ -1 +1 @@
1
- {"version":3,"file":"ios-build.d.ts","sourceRoot":"","sources":["../src/ios-build.ts"],"names":[],"mappings":"AAGA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAEpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAShD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,YAAY,CAAC;IACtB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,UAAU,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CACvD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAcxE;AA0BD,wBAAsB,eAAe,CACnC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,QAAQ,CAAC,CAmMnB"}
1
+ {"version":3,"file":"ios-build.d.ts","sourceRoot":"","sources":["../src/ios-build.ts"],"names":[],"mappings":"AAGA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAEpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;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"}
package/dist/ios-build.js CHANGED
@@ -23,8 +23,15 @@ export function hasIosHost(root, profile) {
23
23
  }
24
24
  function findProject(root, profile) {
25
25
  const configured = profile.ios?.workspace ?? profile.ios?.project;
26
- if (configured)
27
- return resolve(root, configured);
26
+ if (configured) {
27
+ const configuredPath = resolve(root, configured);
28
+ if (configuredPath.endsWith(".xcodeproj")) {
29
+ const workspace = configuredPath.replace(/\.xcodeproj$/, ".xcworkspace");
30
+ if (existsSync(workspace))
31
+ return workspace;
32
+ }
33
+ return configuredPath;
34
+ }
28
35
  for (const directory of ["ios", "macos"]) {
29
36
  try {
30
37
  const candidate = readdirSync(join(root, directory)).find((name) => name.endsWith(".xcworkspace") || name.endsWith(".xcodeproj"));
@@ -37,6 +44,19 @@ function findProject(root, profile) {
37
44
  }
38
45
  throw new Error("No Xcode workspace or project found under ios/ or macos/");
39
46
  }
47
+ async function installCocoaPods(root, quiet, onEvent) {
48
+ const iosDirectory = join(root, "ios");
49
+ const podfile = join(iosDirectory, "Podfile");
50
+ if (!existsSync(podfile))
51
+ return;
52
+ assert(commandExists("pod"), "IOS_COCOAPODS_REQUIRED", "CocoaPods was not found. Install CocoaPods on macOS, then rerun the build.");
53
+ onEvent?.("Installing iOS CocoaPods dependencies…");
54
+ await runProcess("pod", ["install"], {
55
+ cwd: iosDirectory,
56
+ quiet,
57
+ onOutput: onEvent,
58
+ });
59
+ }
40
60
  async function findIpa(directory) {
41
61
  const files = await readdir(directory, { withFileTypes: true });
42
62
  const ipa = files.find((file) => file.isFile() && file.name.endsWith(".ipa"));
@@ -50,6 +70,8 @@ export async function runRealIosBuild(job, options) {
50
70
  assert(commandExists("xcrun"), "IOS_XCRUN_REQUIRED", "xcrun was not found. Install Xcode command-line tools.");
51
71
  const project = findProject(options.root, options.profile);
52
72
  assert(existsSync(project), "IOS_PROJECT_REQUIRED", `Configured Xcode project was not found: ${project}`);
73
+ await installCocoaPods(options.root, options.quiet, options.onEvent);
74
+ const resolvedProject = findProject(options.root, options.profile);
53
75
  const ios = options.profile.ios ?? {};
54
76
  const scheme = ios.scheme;
55
77
  assert(scheme, "IOS_SCHEME_REQUIRED", "Configure build.<profile>.ios.scheme in lynxship.json");
@@ -57,7 +79,7 @@ export async function runRealIosBuild(job, options) {
57
79
  assert(exportOptions, "IOS_EXPORT_OPTIONS_REQUIRED", "Configure build.<profile>.ios.exportOptionsPlist for a signed IPA export");
58
80
  const exportOptionsPath = resolve(options.root, exportOptions);
59
81
  assert(existsSync(exportOptionsPath), "IOS_EXPORT_OPTIONS_REQUIRED", `Export options file was not found: ${exportOptionsPath}`);
60
- const projectFlag = project.endsWith(".xcworkspace")
82
+ const projectFlag = resolvedProject.endsWith(".xcworkspace")
61
83
  ? "-workspace"
62
84
  : "-project";
63
85
  const configuration = ios.configuration ?? "Release";
@@ -92,7 +114,7 @@ export async function runRealIosBuild(job, options) {
92
114
  step("Preparing Xcode archive…", 10);
93
115
  await runProcess("xcodebuild", [
94
116
  projectFlag,
95
- project,
117
+ resolvedProject,
96
118
  "-scheme",
97
119
  scheme,
98
120
  "-configuration",
@@ -0,0 +1,17 @@
1
+ export interface IosHostOptions {
2
+ bundleIdentifier: string;
3
+ appName: string;
4
+ }
5
+ export interface IosHostResult {
6
+ directory: string;
7
+ targetName: string;
8
+ bundleIdentifier: string;
9
+ project: string;
10
+ scheme: string;
11
+ exportOptionsPlist: string;
12
+ bundleScript: string;
13
+ configUpdated: boolean;
14
+ }
15
+ export declare function initializeIosHost(root: string, options: IosHostOptions): Promise<IosHostResult>;
16
+ export declare function suggestedIosBundleIdentifier(root: string): string;
17
+ //# sourceMappingURL=ios-host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ios-host.d.ts","sourceRoot":"","sources":["../src/ios-host.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,cAAc;IAC7B,gBAAgB,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB,EAAE,MAAM,CAAC;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,OAAO,CAAC;CACxB;AAoED,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CA0CxB;AAED,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKjE"}
@@ -0,0 +1,93 @@
1
+ import { access, cp, readdir, readFile, rename, writeFile, } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { basename, join } from "node:path";
4
+ import { assert } from "@lynxship/contracts";
5
+ const templateRoot = fileURLToPath(new URL("../templates/ios-host/", import.meta.url));
6
+ function validateBundleIdentifier(bundleIdentifier) {
7
+ assert(/^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*)+$/.test(bundleIdentifier), "IOS_BUNDLE_IDENTIFIER_INVALID", "iOS bundle identifier must contain at least two dot-separated segments, for example com.example.myapp.");
8
+ }
9
+ async function exists(path) {
10
+ try {
11
+ await access(path);
12
+ return true;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ function targetName(value) {
19
+ const words = value.match(/[a-zA-Z0-9]+/g) ?? [];
20
+ const result = words
21
+ .map((word) => `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}`)
22
+ .join("");
23
+ if (result && /^[A-Za-z]/.test(result))
24
+ return result;
25
+ return `LynxShip${result || "App"}`;
26
+ }
27
+ async function textFiles(directory) {
28
+ const entries = await readdir(directory, { withFileTypes: true });
29
+ const files = [];
30
+ for (const entry of entries) {
31
+ const path = join(directory, entry.name);
32
+ if (entry.isDirectory())
33
+ files.push(...(await textFiles(path)));
34
+ else if ([".h", ".m", ".plist", ".pbxproj", ".swift", ".rb", ".mjs"].some((extension) => entry.name.endsWith(extension)) ||
35
+ entry.name === "Podfile")
36
+ files.push(path);
37
+ }
38
+ return files;
39
+ }
40
+ async function updateProjectConfig(root, result) {
41
+ const configPath = join(root, "lynxship.json");
42
+ if (!(await exists(configPath)))
43
+ return false;
44
+ const config = JSON.parse(await readFile(configPath, "utf8"));
45
+ config.build ??= {};
46
+ config.build.production ??= {};
47
+ config.build.production.ios = {
48
+ ...config.build.production.ios,
49
+ project: `ios/${result.targetName}.xcodeproj`,
50
+ scheme: result.scheme,
51
+ configuration: "Release",
52
+ exportOptionsPlist: "ios/ExportOptions.plist",
53
+ bundleScript: "ios/sync-bundle.mjs",
54
+ };
55
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
56
+ return true;
57
+ }
58
+ export async function initializeIosHost(root, options) {
59
+ validateBundleIdentifier(options.bundleIdentifier);
60
+ const ios = join(root, "ios");
61
+ assert(!(await exists(ios)), "IOS_HOST_EXISTS", `The iOS host already exists at ${ios}. LynxShip will not overwrite it.`);
62
+ const scheme = targetName(options.appName);
63
+ await cp(templateRoot, ios, { recursive: true, force: false });
64
+ await rename(join(ios, "__IOS_TARGET_NAME__"), join(ios, scheme));
65
+ await rename(join(ios, "__IOS_TARGET_NAME__.xcodeproj"), join(ios, `${scheme}.xcodeproj`));
66
+ await rename(join(ios, scheme, "Hello-Lynx-Bridging-Header.h"), join(ios, scheme, `${scheme}-Bridging-Header.h`));
67
+ const files = await textFiles(ios);
68
+ for (const file of files) {
69
+ let content = await readFile(file, "utf8");
70
+ content = content
71
+ .replaceAll("test.Hello-Lynx", options.bundleIdentifier)
72
+ .replaceAll("Hello-Lynx", scheme);
73
+ await writeFile(file, content, "utf8");
74
+ }
75
+ const project = `ios/${scheme}.xcodeproj`;
76
+ const result = {
77
+ directory: ios,
78
+ targetName: scheme,
79
+ bundleIdentifier: options.bundleIdentifier,
80
+ project,
81
+ scheme,
82
+ exportOptionsPlist: "ios/ExportOptions.plist",
83
+ bundleScript: "ios/sync-bundle.mjs",
84
+ };
85
+ const configUpdated = await updateProjectConfig(root, result);
86
+ return { ...result, configUpdated };
87
+ }
88
+ export function suggestedIosBundleIdentifier(root) {
89
+ const project = basename(root)
90
+ .toLowerCase()
91
+ .replace(/[^a-z0-9]+/g, "");
92
+ return `com.example.${project || "lynxapp"}`;
93
+ }