@fireota/cli 0.3.0 → 0.4.0

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/bin/fireota.mjs CHANGED
@@ -11,12 +11,13 @@ import { execFileSync } from "node:child_process";
11
11
  import { createHash } from "node:crypto";
12
12
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
- import { join, resolve } from "node:path";
14
+ import { createRequire } from "node:module";
15
+ import { join, relative, resolve } from "node:path";
15
16
 
16
17
  import { detectCi } from "../lib/ci.mjs";
17
18
  import { runInit } from "../lib/init.mjs";
18
19
  import { packDirectory } from "../lib/package.mjs";
19
- import { storeVersion } from "../lib/project.mjs";
20
+ import { isExpoProject, readExpoAppJson, storeVersion } from "../lib/project.mjs";
20
21
  import { generateKeys, loadPrivateKey, signUpdate } from "../lib/signing.mjs";
21
22
 
22
23
  const HELP = `FireOTA CLI
@@ -101,6 +102,11 @@ function groupIdFrom(seed) {
101
102
  function hermesEnabled(platform, mode) {
102
103
  if (mode === "on") return true;
103
104
  if (mode === "off") return false;
105
+ // Expo without android/ or ios/ yet: the engine is set in app.json (Hermes unless "jsEngine": "jsc").
106
+ if (isExpoProject() && !existsSync(platform === "android" ? "android" : "ios")) {
107
+ const expo = readExpoAppJson() ?? {};
108
+ return (expo[platform]?.jsEngine ?? expo.jsEngine ?? "hermes") !== "jsc";
109
+ }
104
110
  if (platform === "android") {
105
111
  const props = existsSync("android/gradle.properties") ? readFileSync("android/gradle.properties", "utf8") : "";
106
112
  return !/^\s*hermesEnabled\s*=\s*false/m.test(props);
@@ -127,17 +133,29 @@ function bundle(flags, platformOverride) {
127
133
  if (!existsSync("package.json") || !existsSync(join("node_modules", "react-native"))) {
128
134
  fail("Run this from your React Native project root (react-native must be installed).");
129
135
  }
130
- const entry = flags.entry ? String(flags.entry) : existsSync("index.js") ? "index.js" : "index.ts";
136
+ const expo = isExpoProject();
137
+ // Expo apps bundle exactly like their native build does: Expo's entry resolver (expo-router/entry,
138
+ // registerRootComponent in index.ts, …) and `expo export:embed` instead of `react-native bundle`.
139
+ const entry = flags.entry
140
+ ? String(flags.entry)
141
+ : expo
142
+ ? execFileSync(process.execPath, ["-e", "require('expo/scripts/resolveAppEntry')", process.cwd(), platform, "absolute"]).toString().trim()
143
+ : existsSync("index.js")
144
+ ? "index.js"
145
+ : "index.ts";
146
+ const bundler = expo
147
+ ? [createRequire(resolve("package.json")).resolve("@expo/cli", { paths: [createRequire(resolve("package.json")).resolve("expo/package.json")] }), "export:embed"]
148
+ : [join("node_modules", "react-native", "cli.js"), "bundle"];
131
149
  const bundleName = platform === "android" ? "index.android.bundle" : "main.jsbundle";
132
150
  const work = mkdtempSync(join(tmpdir(), "fireota-"));
133
151
  const out = join(work, "package");
134
152
  try {
135
153
  const jsOut = join(work, "bundle.js");
136
- log(`Bundling ${entry} for ${platform}…`);
154
+ log(`Bundling ${relative(".", entry) || entry} for ${platform}${expo ? " (Expo)" : ""}…`);
137
155
  // Run the project's own React Native CLI through Node: no shell, same on every OS.
138
156
  execFileSync(
139
157
  process.execPath,
140
- [join("node_modules", "react-native", "cli.js"), "bundle", "--platform", platform, "--dev", "false", "--minify", "false", "--entry-file", entry, "--bundle-output", jsOut, "--assets-dest", out],
158
+ [...bundler, "--platform", platform, "--dev", "false", "--minify", "false", "--entry-file", entry, "--bundle-output", jsOut, "--assets-dest", out],
141
159
  { stdio: jsonMode ? ["ignore", "ignore", "inherit"] : "inherit" },
142
160
  );
143
161
  // The bundler only creates the assets folder when the app has images.
@@ -287,7 +305,7 @@ async function main() {
287
305
  Runtime version: ${versions.join(", ") || "unknown"} (your store version — bump it when native code changes).
288
306
 
289
307
  Next:
290
- 1. Rebuild the app once (release build / store build): npx react-native run-android --mode release${result.runtime.ios ? "\n iOS: cd ios && pod install" : ""}
308
+ 1. Rebuild the app once (release build / store build): ${result.expo ? "npx expo run:android --variant release (or EAS Build) — prebuild applies the FireOTA plugin" : `npx react-native run-android --mode release${result.runtime.ios ? "\n iOS: cd ios && pod install" : ""}`}
291
309
  2. Publish an update: FIREOTA_URL=${result.serverUrl} FIREOTA_API_KEY=… npm run ota:publish -- --message "What changed"
292
310
  3. Optional: show <UpdateScreen /> from '@fireota/react-native' in your settings.`);
293
311
  if (result.results.some(([mark]) => mark === "!")) console.log("\nSteps marked ! need a manual change — see https://github.com/Sujay-Dynamisch/FireOTA#readme");
package/lib/init.mjs CHANGED
@@ -9,10 +9,10 @@
9
9
  * Every step is idempotent and skipped when already done; nothing is overwritten.
10
10
  */
11
11
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
- import { join, relative } from "node:path";
12
+ import { dirname, join, relative, sep } from "node:path";
13
13
  import { createInterface } from "node:readline/promises";
14
14
 
15
- import { androidVersionName, findFile, iosMarketingVersion } from "./project.mjs";
15
+ import { findFile, isExpoProject, storeVersion } from "./project.mjs";
16
16
 
17
17
  const IMPORT_SDK = "dev.fireota.reactnative.FireOTA";
18
18
 
@@ -39,9 +39,31 @@ function addKotlinImport(source) {
39
39
  }
40
40
 
41
41
  /** Returns the patched MainApplication source, or null when no known template matched. */
42
+ function addHostOverride(source) {
43
+ const host = /object\s*:\s*DefaultReactNativeHost\(this\)\s*\{/.exec(source);
44
+ if (!host) return null;
45
+ const at = host.index + host[0].length;
46
+ const method =
47
+ "\n // FireOTA: release builds load the latest verified OTA bundle.\n" +
48
+ " override fun getJSBundleFile(): String? =\n" +
49
+ " if (BuildConfig.DEBUG) super.getJSBundleFile()\n" +
50
+ " else FireOTA.getJSBundleFile(applicationContext) ?: super.getJSBundleFile()\n";
51
+ return addKotlinImport(source.slice(0, at) + method + source.slice(at));
52
+ }
53
+
54
+ // Keep in sync with packages/react-native/plugin/patches.js (the Expo config plugin);
55
+ // tests/sdk/expo-plugin.test.ts checks both produce the same output.
42
56
  export function patchMainApplication(source, isKotlin) {
43
57
  if (source.includes("FireOTA.getJSBundleFile")) return { source, status: "already" };
44
58
  if (isKotlin) {
59
+ // Expo SDK 50–55: ReactNativeHostWrapper(this, object : DefaultReactNativeHost(this) { … }), and the host
60
+ // factory of those SDKs takes no jsBundleFilePath — override getJSBundleFile() on the wrapped host.
61
+ // Expo SDK 56+ calls ExpoReactHostFactory.getDefaultReactHost(context = …, packageList = …), which accepts
62
+ // jsBundleFilePath like React Native's own factory, so it falls through to the branch below.
63
+ if (source.includes("ReactNativeHostWrapper")) {
64
+ const patched = addHostOverride(source);
65
+ if (patched) return { source: patched, status: "patched" };
66
+ }
45
67
  // React Native 0.76+: getDefaultReactHost(context = …, packageList = …)
46
68
  const call = source.indexOf("getDefaultReactHost(");
47
69
  if (call !== -1) {
@@ -58,17 +80,8 @@ export function patchMainApplication(source, isKotlin) {
58
80
  }
59
81
  }
60
82
  // Older templates: object : DefaultReactNativeHost(this) { … }
61
- const host = /object\s*:\s*DefaultReactNativeHost\(this\)\s*\{/.exec(source);
62
- if (host) {
63
- const at = host.index + host[0].length;
64
- const method =
65
- "\n // FireOTA: release builds load the latest verified OTA bundle.\n" +
66
- " override fun getJSBundleFile(): String? =\n" +
67
- " if (BuildConfig.DEBUG) super.getJSBundleFile()\n" +
68
- " else FireOTA.getJSBundleFile(applicationContext) ?: super.getJSBundleFile()\n";
69
- return { source: addKotlinImport(source.slice(0, at) + method + source.slice(at)), status: "patched" };
70
- }
71
- return { source, status: "unknown" };
83
+ const patched = addHostOverride(source);
84
+ return patched ? { source: patched, status: "patched" } : { source, status: "unknown" };
72
85
  }
73
86
  const host = /new\s+DefaultReactNativeHost\(this\)\s*\{/.exec(source);
74
87
  if (!host) return { source, status: "unknown" };
@@ -122,6 +135,50 @@ export function patchEntry(source, configImport) {
122
135
  return { source: patched, status: "patched" };
123
136
  }
124
137
 
138
+ function addEntryImports(source, configImport) {
139
+ const lines = `import { withFireOTA } from '@fireota/react-native';\nimport fireotaConfig from '${configImport}';`;
140
+ const imports = [...source.matchAll(/^import .+$/gm)];
141
+ const last = imports.at(-1);
142
+ return last ? `${source.slice(0, last.index + last[0].length)}\n${lines}${source.slice(last.index + last[0].length)}` : `${lines}\n${source}`;
143
+ }
144
+
145
+ /** Expo (no router): registerRootComponent(App) -> registerRootComponent(withFireOTA(App, fireotaConfig)). */
146
+ export function patchRegisterRootComponent(source, configImport) {
147
+ if (source.includes("withFireOTA")) return { source, status: "already" };
148
+ const re = /registerRootComponent\(\s*([A-Za-z_$][\w$]*)\s*\)/;
149
+ if (!re.test(source)) return { source, status: "unknown" };
150
+ const patched = source.replace(re, "registerRootComponent(withFireOTA($1, fireotaConfig))");
151
+ return { source: addEntryImports(patched, configImport), status: "patched" };
152
+ }
153
+
154
+ /** expo-router: wrap the root layout (app/_layout.tsx) — it is the root component of the app. */
155
+ export function patchExpoRouterLayout(source, configImport) {
156
+ if (source.includes("withFireOTA")) return { source, status: "already" };
157
+ const named = /export\s+default\s+function\s+([A-Za-z_$][\w$]*)\s*\(/.exec(source);
158
+ if (named) {
159
+ const name = named[1];
160
+ const patched = `${source.replace(named[0], `function ${name}(`).replace(/\s*$/, "")}\n\nexport default withFireOTA(${name}, fireotaConfig);\n`;
161
+ return { source: addEntryImports(patched, configImport), status: "patched" };
162
+ }
163
+ const reexport = /export\s+default\s+([A-Za-z_$][\w$]*)\s*;?\s*$/m.exec(source);
164
+ if (reexport) {
165
+ const patched = source.replace(reexport[0], `export default withFireOTA(${reexport[1]}, fireotaConfig);`);
166
+ return { source: addEntryImports(patched, configImport), status: "patched" };
167
+ }
168
+ return { source, status: "unknown" };
169
+ }
170
+
171
+ /** Adds "@fireota/react-native" to expo.plugins in app.json. */
172
+ export function addExpoPlugin(appJsonText) {
173
+ const json = JSON.parse(appJsonText);
174
+ const expo = json.expo ?? json;
175
+ const plugins = Array.isArray(expo.plugins) ? expo.plugins : [];
176
+ const has = plugins.some((p) => (Array.isArray(p) ? p[0] : p) === "@fireota/react-native");
177
+ if (has) return { source: appJsonText, status: "already" };
178
+ expo.plugins = [...plugins, "@fireota/react-native"];
179
+ return { source: `${JSON.stringify(json, null, 2)}\n`, status: "patched" };
180
+ }
181
+
125
182
  function configFile({ appId, serverUrl, channel, certificate }) {
126
183
  return `// FireOTA settings — created by \`npx fireota init\`. No secrets here; safe to commit.
127
184
  // runtimeVersion defaults to the app's store version (Android versionName / iOS
@@ -188,9 +245,30 @@ export async function runInit(flags, { api, log, fail }) {
188
245
  else write(configPath, configFile({ appId, serverUrl, channel, certificate }), `${configPath} created${certificate ? " (with code signing certificate)" : ""}`);
189
246
 
190
247
  // 2. JS entry ------------------------------------------------------------
248
+ const expo = isExpoProject();
249
+ const pkgMain = JSON.parse(readFileSync("package.json", "utf8")).main ?? "";
191
250
  const entry = ["index.js", "index.ts", "index.tsx"].find((f) => existsSync(f));
192
- if (!entry) results.push(["!", "No index.js found — wrap your root component: withFireOTA(App, fireotaConfig)"]);
193
- else {
251
+ if (expo && /expo-router\/entry/.test(pkgMain)) {
252
+ // expo-router: the root layout is the app's root component.
253
+ const layout = ["app", join("src", "app")]
254
+ .flatMap((dir) => ["_layout.tsx", "_layout.jsx", "_layout.ts", "_layout.js"].map((f) => join(dir, f)))
255
+ .find((f) => existsSync(f));
256
+ if (!layout) results.push(["!", "expo-router: no app/_layout.tsx found — wrap your root layout: export default withFireOTA(RootLayout, fireotaConfig)"]);
257
+ else {
258
+ const configImport = relative(dirname(layout), "fireota.config").split(sep).join("/");
259
+ const res = patchExpoRouterLayout(readFileSync(layout, "utf8"), configImport.startsWith(".") ? configImport : `./${configImport}`);
260
+ const rel = relative(".", layout);
261
+ if (res.status === "patched") write(layout, res.source, `${rel}: root layout wrapped with withFireOTA()`);
262
+ else if (res.status === "already") results.push(["–", `${rel} already uses withFireOTA`]);
263
+ else results.push(["!", `${rel}: wrap your root layout manually: export default withFireOTA(RootLayout, fireotaConfig)`]);
264
+ }
265
+ } else if (!entry) results.push(["!", "No index.js found — wrap your root component: withFireOTA(App, fireotaConfig)"]);
266
+ else if (expo && /registerRootComponent\(/.test(readFileSync(entry, "utf8"))) {
267
+ const res = patchRegisterRootComponent(readFileSync(entry, "utf8"), "./fireota.config");
268
+ if (res.status === "patched") write(entry, res.source, `${entry}: root component wrapped with withFireOTA()`);
269
+ else if (res.status === "already") results.push(["–", `${entry} already uses withFireOTA`]);
270
+ else results.push(["!", `${entry}: wrap your root component manually: registerRootComponent(withFireOTA(App, fireotaConfig))`]);
271
+ } else {
194
272
  const res = patchEntry(readFileSync(entry, "utf8"), "./fireota.config");
195
273
  if (res.status === "patched") write(entry, res.source, `${entry}: root component wrapped with withFireOTA()`);
196
274
  else if (res.status === "already") results.push(["–", `${entry} already uses withFireOTA`]);
@@ -198,9 +276,26 @@ export async function runInit(flags, { api, log, fail }) {
198
276
  else results.push(["!", `${entry}: wrap your root component manually: withFireOTA(App, fireotaConfig)`]);
199
277
  }
200
278
 
201
- // 3. Android -------------------------------------------------------------
279
+ // 3. Expo config plugin --------------------------------------------------
280
+ // `expo prebuild` / EAS Build regenerate android/ and ios/, so the plugin re-applies the native changes.
281
+ if (expo) {
282
+ if (existsSync("app.json")) {
283
+ const res = addExpoPlugin(readFileSync("app.json", "utf8"));
284
+ if (res.status === "patched") write("app.json", res.source, 'app.json: added the "@fireota/react-native" config plugin');
285
+ else results.push(["–", "app.json already uses the FireOTA config plugin"]);
286
+ } else {
287
+ results.push(["!", 'app.config.js/ts: add "@fireota/react-native" to plugins (dynamic configs are not edited)']);
288
+ }
289
+ const deps = JSON.parse(readFileSync("package.json", "utf8"));
290
+ if (deps.dependencies?.["expo-updates"] || deps.devDependencies?.["expo-updates"]) {
291
+ results.push(["!", "expo-updates is installed — remove it (npm uninstall expo-updates); it cannot run alongside FireOTA"]);
292
+ }
293
+ }
294
+
295
+ // 4. Android -------------------------------------------------------------
202
296
  const mainApp = findFile(join("android", "app", "src", "main"), (n) => n === "MainApplication.kt" || n === "MainApplication.java");
203
- if (!mainApp) results.push(["!", "Android: MainApplication not found — see the Android setup docs"]);
297
+ if (!mainApp && expo) results.push(["–", "Android: no android/ folder yet — the config plugin wires it on expo prebuild / EAS Build"]);
298
+ else if (!mainApp) results.push(["!", "Android: MainApplication not found — see the Android setup docs"]);
204
299
  else {
205
300
  const res = patchMainApplication(readFileSync(mainApp, "utf8"), mainApp.endsWith(".kt"));
206
301
  const rel = relative(".", mainApp);
@@ -209,9 +304,10 @@ export async function runInit(flags, { api, log, fail }) {
209
304
  else results.push(["!", `${rel}: unknown template — add FireOTA.getJSBundleFile() as in the Android setup docs`]);
210
305
  }
211
306
 
212
- // 4. iOS -----------------------------------------------------------------
307
+ // 5. iOS -----------------------------------------------------------------
213
308
  const delegate = findFile("ios", (n) => n === "AppDelegate.swift" || n === "AppDelegate.mm" || n === "AppDelegate.m", 3);
214
- if (!delegate) results.push(["–", "iOS: no AppDelegate found (skipped)"]);
309
+ if (!delegate && expo) results.push(["–", "iOS: no ios/ folder yet — the config plugin wires it on expo prebuild / EAS Build"]);
310
+ else if (!delegate) results.push(["–", "iOS: no AppDelegate found (skipped)"]);
215
311
  else {
216
312
  const res = patchAppDelegate(readFileSync(delegate, "utf8"), delegate.endsWith(".swift"));
217
313
  const rel = relative(".", delegate);
@@ -220,7 +316,7 @@ export async function runInit(flags, { api, log, fail }) {
220
316
  else results.push(["!", `${rel}: unknown template — return [FireOTA bundleURL] in bundleURL (see iOS setup docs)`]);
221
317
  }
222
318
 
223
- // 5. package.json script + .gitignore ------------------------------------
319
+ // 6. package.json script + .gitignore ------------------------------------
224
320
  const pkg = JSON.parse(readFileSync("package.json", "utf8"));
225
321
  pkg.scripts ??= {};
226
322
  if (pkg.scripts["ota:publish"]) results.push(["–", 'package.json already has "ota:publish"']);
@@ -233,6 +329,6 @@ export async function runInit(flags, { api, log, fail }) {
233
329
  if (missing.length === 0) results.push(["–", ".gitignore already covers FireOTA files"]);
234
330
  else write(".gitignore", `${ignore.replace(/\s*$/, "\n")}\n# FireOTA\n${missing.join("\n")}\n`, `.gitignore: ignores ${missing.join(", ")}`);
235
331
 
236
- const runtime = { android: androidVersionName(), ios: iosMarketingVersion() };
237
- return { results, dry, appId, serverUrl, channel, runtime };
332
+ const runtime = { android: storeVersion("android"), ios: storeVersion("ios") };
333
+ return { results, dry, appId, serverUrl, channel, runtime, expo };
238
334
  }
package/lib/project.mjs CHANGED
@@ -33,9 +33,33 @@ export function iosMarketingVersion(root = ".") {
33
33
  return undefined;
34
34
  }
35
35
 
36
+ /** True for Expo projects (the `expo` package is a dependency). */
37
+ export function isExpoProject(root = ".") {
38
+ const path = join(root, "package.json");
39
+ if (!existsSync(path)) return false;
40
+ const pkg = JSON.parse(readFileSync(path, "utf8"));
41
+ return Boolean(pkg.dependencies?.expo ?? pkg.devDependencies?.expo);
42
+ }
43
+
44
+ /** The `expo` object of app.json, or null (no app.json, or a dynamic app.config.js / .ts). */
45
+ export function readExpoAppJson(root = ".") {
46
+ const path = join(root, "app.json");
47
+ if (!existsSync(path)) return null;
48
+ const json = JSON.parse(readFileSync(path, "utf8"));
49
+ return json.expo ?? json;
50
+ }
51
+
52
+ /** Expo writes `expo.version` into versionName / CFBundleShortVersionString at prebuild. */
53
+ export function expoVersion(root = ".") {
54
+ const version = readExpoAppJson(root)?.version;
55
+ return typeof version === "string" && version ? version : undefined;
56
+ }
57
+
36
58
  /** The app's store version for a platform — FireOTA's default runtime version. */
37
59
  export function storeVersion(platform, root = ".") {
38
- return platform === "ios" ? iosMarketingVersion(root) : androidVersionName(root);
60
+ const native = platform === "ios" ? iosMarketingVersion(root) : androidVersionName(root);
61
+ // Expo projects without android/ or ios/ yet (Continuous Native Generation): use app.json.
62
+ return native ?? (isExpoProject(root) ? expoVersion(root) : undefined);
39
63
  }
40
64
 
41
65
  /** Recursively finds the first file matching a name test below a directory. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fireota/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Build FireOTA update packages and publish React Native OTA releases from a terminal or CI.",
5
5
  "license": "MIT",
6
6
  "type": "module",