@lynxship/cli 0.1.4 → 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 (41) hide show
  1. package/README.md +48 -2
  2. package/dist/android-host.d.ts +12 -0
  3. package/dist/android-host.d.ts.map +1 -0
  4. package/dist/android-host.js +70 -0
  5. package/dist/index.js +97 -4
  6. package/dist/ios-build.d.ts.map +1 -1
  7. package/dist/ios-build.js +26 -4
  8. package/dist/ios-host.d.ts +17 -0
  9. package/dist/ios-host.d.ts.map +1 -0
  10. package/dist/ios-host.js +93 -0
  11. package/package.json +6 -5
  12. package/templates/android-host/app/build.gradle +38 -0
  13. package/templates/android-host/app/proguard-rules.pro +2 -0
  14. package/templates/android-host/app/src/main/AndroidManifest.xml +20 -0
  15. package/templates/android-host/app/src/main/java/template/LynxShipApplication.java +13 -0
  16. package/templates/android-host/app/src/main/java/template/MainActivity.java +19 -0
  17. package/templates/android-host/app/src/main/java/template/ProjectTemplateProvider.java +32 -0
  18. package/templates/android-host/app/src/main/res/values/strings.xml +3 -0
  19. package/templates/android-host/app/src/main/res/values/themes.xml +6 -0
  20. package/templates/android-host/build.gradle +3 -0
  21. package/templates/android-host/gradle/wrapper/gradle-wrapper.jar +0 -0
  22. package/templates/android-host/gradle/wrapper/gradle-wrapper.properties +7 -0
  23. package/templates/android-host/gradle.properties +3 -0
  24. package/templates/android-host/gradlew +185 -0
  25. package/templates/android-host/gradlew.bat +89 -0
  26. package/templates/android-host/settings.gradle +20 -0
  27. package/templates/ios-host/ExportOptions.plist +16 -0
  28. package/templates/ios-host/Podfile +42 -0
  29. package/templates/ios-host/__IOS_TARGET_NAME__/AppDelegate.swift +11 -0
  30. package/templates/ios-host/__IOS_TARGET_NAME__/Assets.xcassets/AccentColor.colorset/Contents.json +11 -0
  31. package/templates/ios-host/__IOS_TARGET_NAME__/Assets.xcassets/AppIcon.appiconset/Contents.json +13 -0
  32. package/templates/ios-host/__IOS_TARGET_NAME__/Assets.xcassets/Contents.json +6 -0
  33. package/templates/ios-host/__IOS_TARGET_NAME__/Base.lproj/LaunchScreen.storyboard +25 -0
  34. package/templates/ios-host/__IOS_TARGET_NAME__/Base.lproj/Main.storyboard +24 -0
  35. package/templates/ios-host/__IOS_TARGET_NAME__/DemoLynxProvider.swift +18 -0
  36. package/templates/ios-host/__IOS_TARGET_NAME__/Hello-Lynx-Bridging-Header.h +4 -0
  37. package/templates/ios-host/__IOS_TARGET_NAME__/Info.plist +25 -0
  38. package/templates/ios-host/__IOS_TARGET_NAME__/SceneDelegate.swift +49 -0
  39. package/templates/ios-host/__IOS_TARGET_NAME__/ViewController.swift +22 -0
  40. package/templates/ios-host/__IOS_TARGET_NAME__.xcodeproj/project.pbxproj +452 -0
  41. package/templates/ios-host/sync-bundle.mjs +11 -0
package/README.md CHANGED
@@ -117,7 +117,7 @@ unverified artifact.
117
117
  ```text
118
118
  init Initialize or link a project
119
119
  doctor Check the local toolchain and project
120
- dev Run the Rspeedy development server
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
123
  build list List build jobs
@@ -133,7 +133,8 @@ logs Stream native logs
133
133
  autolink check Check Lynx native-library wiring
134
134
  autolink codegen Run native-module codegen
135
135
  ota doctor Check native OTA host integration
136
- storage configure Configure Cloudflare R2
136
+ storage configure Configure Cloudflare R2
137
+ android host init Create a minimal official Lynx Android host
137
138
  android configure Configure Android signing
138
139
  store configure Configure store submission credentials
139
140
  ```
@@ -170,6 +171,51 @@ command-line tools with `sdkmanager`, accept the required licenses, set
170
171
  `android/gradlew` is executable. macOS can build both Android and iOS; Windows
171
172
  and Linux can build Android only.
172
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
+
173
219
  ## Package layout
174
220
 
175
221
  The CLI is backed by the public `@lynxship/*` runtime packages in this
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lynxship/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Build, sign, store, submit and update LynxJS applications from the terminal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,8 @@
25
25
  ],
26
26
  "files": [
27
27
  "dist",
28
- "README.md"
28
+ "README.md",
29
+ "templates"
29
30
  ],
30
31
  "engines": {
31
32
  "node": ">=22.0.0"
@@ -68,10 +69,10 @@
68
69
  "ora": "8.2.0",
69
70
  "qrcode-terminal": "0.12.0",
70
71
  "@lynxship/build-orchestrator": "0.1.0",
71
- "@lynxship/db": "0.1.0",
72
72
  "@lynxship/contracts": "0.1.0",
73
- "@lynxship/signing": "0.1.0",
74
- "@lynxship/submit": "0.1.0"
73
+ "@lynxship/submit": "0.1.0",
74
+ "@lynxship/db": "0.1.0",
75
+ "@lynxship/signing": "0.1.0"
75
76
  },
76
77
  "devDependencies": {
77
78
  "@types/cli-progress": "3.11.6",
@@ -0,0 +1,38 @@
1
+ plugins {
2
+ id "com.android.application"
3
+ }
4
+
5
+ android {
6
+ namespace "__APPLICATION_ID__"
7
+ compileSdk 35
8
+
9
+ defaultConfig {
10
+ applicationId "__APPLICATION_ID__"
11
+ minSdk 24
12
+ targetSdk 35
13
+ versionCode 1
14
+ versionName "0.1.0"
15
+ }
16
+
17
+ buildTypes {
18
+ release {
19
+ minifyEnabled false
20
+ proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"),
21
+ "proguard-rules.pro"
22
+ }
23
+ }
24
+
25
+ compileOptions {
26
+ sourceCompatibility JavaVersion.VERSION_17
27
+ targetCompatibility JavaVersion.VERSION_17
28
+ }
29
+ }
30
+
31
+ dependencies {
32
+ implementation "org.lynxsdk.lynx:lynx:4.0.0"
33
+ implementation "org.lynxsdk.lynx:lynx-jssdk:4.0.0"
34
+ implementation "org.lynxsdk.lynx:lynx-trace:4.0.0"
35
+ implementation "org.lynxsdk.lynx:primjs:4.0.0"
36
+ implementation "org.lynxsdk.lynx:lynx-service-log:4.0.0"
37
+ implementation "org.lynxsdk.lynx:lynx-service-http:4.0.0"
38
+ }
@@ -0,0 +1,2 @@
1
+ # Lynx's official release rules are supplied by the Lynx Android artifacts.
2
+ # Add application-specific keep rules here when native modules require them.
@@ -0,0 +1,20 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <uses-permission android:name="android.permission.INTERNET" />
4
+
5
+ <application
6
+ android:allowBackup="false"
7
+ android:name=".LynxShipApplication"
8
+ android:label="@string/app_name"
9
+ android:supportsRtl="true"
10
+ android:theme="@style/AppTheme">
11
+ <activity
12
+ android:name=".MainActivity"
13
+ android:exported="true">
14
+ <intent-filter>
15
+ <action android:name="android.intent.action.MAIN" />
16
+ <category android:name="android.intent.category.LAUNCHER" />
17
+ </intent-filter>
18
+ </activity>
19
+ </application>
20
+ </manifest>
@@ -0,0 +1,13 @@
1
+ package __PACKAGE_NAME__;
2
+
3
+ import android.app.Application;
4
+
5
+ import com.lynx.tasm.LynxEnv;
6
+
7
+ public final class LynxShipApplication extends Application {
8
+ @Override
9
+ public void onCreate() {
10
+ super.onCreate();
11
+ LynxEnv.inst().init(this, null, null, null);
12
+ }
13
+ }
@@ -0,0 +1,19 @@
1
+ package __PACKAGE_NAME__;
2
+
3
+ import android.app.Activity;
4
+ import android.os.Bundle;
5
+
6
+ import com.lynx.tasm.LynxView;
7
+ import com.lynx.tasm.LynxViewBuilder;
8
+
9
+ public final class MainActivity extends Activity {
10
+ @Override
11
+ protected void onCreate(Bundle savedInstanceState) {
12
+ super.onCreate(savedInstanceState);
13
+ LynxView lynxView = new LynxViewBuilder()
14
+ .setTemplateProvider(new ProjectTemplateProvider(this))
15
+ .build(this);
16
+ setContentView(lynxView);
17
+ lynxView.renderTemplateUrl("main.lynx.bundle", "");
18
+ }
19
+ }