@fireota/cli 0.2.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/README.md +2 -1
- package/bin/fireota.mjs +62 -9
- package/lib/init.mjs +334 -0
- package/lib/project.mjs +78 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,7 +63,8 @@ Paste `fireota-public.pem` into the dashboard (app → Code signing), pass the s
|
|
|
63
63
|
## Commands
|
|
64
64
|
|
|
65
65
|
```text
|
|
66
|
-
fireota
|
|
66
|
+
fireota init [--app <appId>] [--server-url <url>] [--channel production] [--dry-run]
|
|
67
|
+
fireota publish --app <appId> --platform <android|ios|all> [--runtime <x.y.z>] --message "…"
|
|
67
68
|
[--channel production] [--rollout 10] [--version <label>] [--mandatory] [--native ">=1.4.0"]
|
|
68
69
|
[--file update.fota] [--private-key key.pem] [--json]
|
|
69
70
|
fireota bundle --platform <android|ios> [--entry index.js] [--out update.fota] [--hermes auto|on|off]
|
package/bin/fireota.mjs
CHANGED
|
@@ -11,16 +11,22 @@ 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 {
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { join, relative, resolve } from "node:path";
|
|
15
16
|
|
|
16
17
|
import { detectCi } from "../lib/ci.mjs";
|
|
18
|
+
import { runInit } from "../lib/init.mjs";
|
|
17
19
|
import { packDirectory } from "../lib/package.mjs";
|
|
20
|
+
import { isExpoProject, readExpoAppJson, storeVersion } from "../lib/project.mjs";
|
|
18
21
|
import { generateKeys, loadPrivateKey, signUpdate } from "../lib/signing.mjs";
|
|
19
22
|
|
|
20
23
|
const HELP = `FireOTA CLI
|
|
21
24
|
|
|
22
25
|
Usage:
|
|
23
|
-
fireota
|
|
26
|
+
fireota init [--app <appId>] [--server-url <url>] [--channel production] [--dry-run] [--force]
|
|
27
|
+
One-time setup: creates fireota.config.js, wraps your root component with the
|
|
28
|
+
built-in update UI, and wires MainApplication / AppDelegate for release builds.
|
|
29
|
+
fireota publish --app <appId> --platform <android|ios|all> [--runtime <x.y.z>] --message "What changed"
|
|
24
30
|
[--channel production] [--rollout 10] [--version <label>] [--mandatory] [--native ">=1.4.0"]
|
|
25
31
|
[--file update.fota] [--private-key fireota-private.pem] [--json]
|
|
26
32
|
Builds (unless --file), uploads to your Firebase Storage and publishes.
|
|
@@ -37,7 +43,9 @@ Usage:
|
|
|
37
43
|
fireota releases [--app <appId>] [--json]
|
|
38
44
|
|
|
39
45
|
Updates are identified by their ID (like Expo): --version is only an optional label, and
|
|
40
|
-
--runtime must equal the runtime version of the store build the update is for.
|
|
46
|
+
--runtime must equal the runtime version of the store build the update is for. Without
|
|
47
|
+
--runtime, the app's store version is used (android/app/build.gradle versionName, iOS
|
|
48
|
+
MARKETING_VERSION) — the same default the SDK uses.
|
|
41
49
|
|
|
42
50
|
Environment: FIREOTA_URL, FIREOTA_API_KEY (required); FIREOTA_APP_ID, FIREOTA_CHANNEL,
|
|
43
51
|
FIREOTA_RUNTIME, FIREOTA_SIGNING_KEY / FIREOTA_SIGNING_KEY_PATH (optional).
|
|
@@ -94,6 +102,11 @@ function groupIdFrom(seed) {
|
|
|
94
102
|
function hermesEnabled(platform, mode) {
|
|
95
103
|
if (mode === "on") return true;
|
|
96
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
|
+
}
|
|
97
110
|
if (platform === "android") {
|
|
98
111
|
const props = existsSync("android/gradle.properties") ? readFileSync("android/gradle.properties", "utf8") : "";
|
|
99
112
|
return !/^\s*hermesEnabled\s*=\s*false/m.test(props);
|
|
@@ -120,17 +133,29 @@ function bundle(flags, platformOverride) {
|
|
|
120
133
|
if (!existsSync("package.json") || !existsSync(join("node_modules", "react-native"))) {
|
|
121
134
|
fail("Run this from your React Native project root (react-native must be installed).");
|
|
122
135
|
}
|
|
123
|
-
const
|
|
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"];
|
|
124
149
|
const bundleName = platform === "android" ? "index.android.bundle" : "main.jsbundle";
|
|
125
150
|
const work = mkdtempSync(join(tmpdir(), "fireota-"));
|
|
126
151
|
const out = join(work, "package");
|
|
127
152
|
try {
|
|
128
153
|
const jsOut = join(work, "bundle.js");
|
|
129
|
-
log(`Bundling ${entry} for ${platform}…`);
|
|
154
|
+
log(`Bundling ${relative(".", entry) || entry} for ${platform}${expo ? " (Expo)" : ""}…`);
|
|
130
155
|
// Run the project's own React Native CLI through Node: no shell, same on every OS.
|
|
131
156
|
execFileSync(
|
|
132
157
|
process.execPath,
|
|
133
|
-
[
|
|
158
|
+
[...bundler, "--platform", platform, "--dev", "false", "--minify", "false", "--entry-file", entry, "--bundle-output", jsOut, "--assets-dest", out],
|
|
134
159
|
{ stdio: jsonMode ? ["ignore", "ignore", "inherit"] : "inherit" },
|
|
135
160
|
);
|
|
136
161
|
// The bundler only creates the assets folder when the app has images.
|
|
@@ -182,6 +207,15 @@ async function api(method, path, body, extraHeaders = {}) {
|
|
|
182
207
|
return json.data;
|
|
183
208
|
}
|
|
184
209
|
|
|
210
|
+
/** --runtime / FIREOTA_RUNTIME, else the app's store version for that platform. */
|
|
211
|
+
function runtimeFor(flags, platform) {
|
|
212
|
+
const explicit = flags.runtime ?? process.env.FIREOTA_RUNTIME;
|
|
213
|
+
if (explicit && explicit !== true) return String(explicit);
|
|
214
|
+
const detected = storeVersion(platform);
|
|
215
|
+
if (!detected) fail(`Could not read the ${platform} app version; pass --runtime <x.y.z>.`);
|
|
216
|
+
return detected;
|
|
217
|
+
}
|
|
218
|
+
|
|
185
219
|
async function createRelease(flags, platform, extra = {}) {
|
|
186
220
|
const ci = detectCi();
|
|
187
221
|
const message = flags.message ?? flags.notes;
|
|
@@ -189,7 +223,7 @@ async function createRelease(flags, platform, extra = {}) {
|
|
|
189
223
|
appId: need(flags, "app", "FIREOTA_APP_ID"),
|
|
190
224
|
channel: String(flags.channel ?? process.env.FIREOTA_CHANNEL ?? "production"),
|
|
191
225
|
platform,
|
|
192
|
-
runtimeVersion:
|
|
226
|
+
runtimeVersion: runtimeFor(flags, platform),
|
|
193
227
|
...(flags.version && flags.version !== true ? { version: String(flags.version) } : {}),
|
|
194
228
|
...(message && message !== true ? { releaseNotes: String(message) } : {}),
|
|
195
229
|
...(flags.mandatory ? { mandatory: true } : {}),
|
|
@@ -231,7 +265,7 @@ async function releaseAction(releaseId, body) {
|
|
|
231
265
|
async function publishOne(flags, platform, file, extra, privateKey) {
|
|
232
266
|
const release = await createRelease(flags, platform, extra);
|
|
233
267
|
if (["draft", "failed", "uploading"].includes(release.status)) {
|
|
234
|
-
await uploadRelease(release.id, file, privateKey ? { key: privateKey, runtimeVersion:
|
|
268
|
+
await uploadRelease(release.id, file, privateKey ? { key: privateKey, runtimeVersion: runtimeFor(flags, platform), platform } : null);
|
|
235
269
|
} else {
|
|
236
270
|
log(`Bundle already uploaded (status ${release.status}); skipping upload.`);
|
|
237
271
|
}
|
|
@@ -258,6 +292,25 @@ async function main() {
|
|
|
258
292
|
bundle(parseArgs(process.argv.slice(3)).flags);
|
|
259
293
|
return;
|
|
260
294
|
}
|
|
295
|
+
if (command === "init") {
|
|
296
|
+
const all = parseArgs(process.argv.slice(3)).flags;
|
|
297
|
+
const result = await runInit(all, { api, log, fail });
|
|
298
|
+
console.log(result.dry ? "\nFireOTA init (dry run — nothing written):\n" : "\nFireOTA is set up:\n");
|
|
299
|
+
for (const [mark, text] of result.results) console.log(` ${mark} ${text}`);
|
|
300
|
+
const versions = [
|
|
301
|
+
result.runtime.android ? `Android ${result.runtime.android}` : null,
|
|
302
|
+
result.runtime.ios ? `iOS ${result.runtime.ios}` : null,
|
|
303
|
+
].filter(Boolean);
|
|
304
|
+
console.log(`
|
|
305
|
+
Runtime version: ${versions.join(", ") || "unknown"} (your store version — bump it when native code changes).
|
|
306
|
+
|
|
307
|
+
Next:
|
|
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" : ""}`}
|
|
309
|
+
2. Publish an update: FIREOTA_URL=${result.serverUrl} FIREOTA_API_KEY=… npm run ota:publish -- --message "What changed"
|
|
310
|
+
3. Optional: show <UpdateScreen /> from '@fireota/react-native' in your settings.`);
|
|
311
|
+
if (result.results.some(([mark]) => mark === "!")) console.log("\nSteps marked ! need a manual change — see https://github.com/Sujay-Dynamisch/FireOTA#readme");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
261
314
|
if (command === "whoami") {
|
|
262
315
|
const me = await api("GET", "/me");
|
|
263
316
|
if (jsonMode) {
|
|
@@ -312,7 +365,7 @@ Next:
|
|
|
312
365
|
// Android + iOS built from the same commit form one update group (Expo-style).
|
|
313
366
|
const extra =
|
|
314
367
|
platform === "all"
|
|
315
|
-
? { groupId: groupIdFrom(platforms.map((p) => sha256(readFileSync(files[p]))).join(":")
|
|
368
|
+
? { groupId: groupIdFrom(platforms.map((p) => `${sha256(readFileSync(files[p]))}@${runtimeFor(all, p)}`).join(":")) }
|
|
316
369
|
: {};
|
|
317
370
|
const results = [];
|
|
318
371
|
for (const p of platforms) results.push(await publishOne(all, p, files[p], extra, privateKey));
|
package/lib/init.mjs
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx fireota init` — wires FireOTA into a React Native project in one step:
|
|
3
|
+
* fireota.config.js app ID, server URL, channel (no secrets; safe to commit)
|
|
4
|
+
* index.js wraps the root component with withFireOTA(App, config)
|
|
5
|
+
* MainApplication release builds load the latest verified OTA bundle (Android)
|
|
6
|
+
* AppDelegate release builds load the latest verified OTA bundle (iOS)
|
|
7
|
+
* package.json "ota:publish" script
|
|
8
|
+
* .gitignore update packages and the code signing private key
|
|
9
|
+
* Every step is idempotent and skipped when already done; nothing is overwritten.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
13
|
+
import { createInterface } from "node:readline/promises";
|
|
14
|
+
|
|
15
|
+
import { findFile, isExpoProject, storeVersion } from "./project.mjs";
|
|
16
|
+
|
|
17
|
+
const IMPORT_SDK = "dev.fireota.reactnative.FireOTA";
|
|
18
|
+
|
|
19
|
+
/** Finds the ")" that closes the "(" at `open`. */
|
|
20
|
+
function closingParen(text, open) {
|
|
21
|
+
let depth = 0;
|
|
22
|
+
for (let i = open; i < text.length; i += 1) {
|
|
23
|
+
if (text[i] === "(") depth += 1;
|
|
24
|
+
else if (text[i] === ")") {
|
|
25
|
+
depth -= 1;
|
|
26
|
+
if (depth === 0) return i;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return -1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function addKotlinImport(source) {
|
|
33
|
+
if (source.includes(`import ${IMPORT_SDK}`)) return source;
|
|
34
|
+
const imports = [...source.matchAll(/^import .+$/gm)];
|
|
35
|
+
const last = imports.at(-1);
|
|
36
|
+
if (!last) return source.replace(/^(package .+)$/m, `$1\n\nimport ${IMPORT_SDK}`);
|
|
37
|
+
const at = last.index + last[0].length;
|
|
38
|
+
return `${source.slice(0, at)}\nimport ${IMPORT_SDK}${source.slice(at)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
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.
|
|
56
|
+
export function patchMainApplication(source, isKotlin) {
|
|
57
|
+
if (source.includes("FireOTA.getJSBundleFile")) return { source, status: "already" };
|
|
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
|
+
}
|
|
67
|
+
// React Native 0.76+: getDefaultReactHost(context = …, packageList = …)
|
|
68
|
+
const call = source.indexOf("getDefaultReactHost(");
|
|
69
|
+
if (call !== -1) {
|
|
70
|
+
const open = call + "getDefaultReactHost".length;
|
|
71
|
+
const close = closingParen(source, open);
|
|
72
|
+
if (close !== -1) {
|
|
73
|
+
let head = source.slice(0, close).replace(/\s+$/, "");
|
|
74
|
+
if (!head.endsWith(",") && !head.endsWith("(")) head += ",";
|
|
75
|
+
const patched =
|
|
76
|
+
`${head}\n // FireOTA: release builds load the latest verified OTA bundle (null = bundle in the APK).\n` +
|
|
77
|
+
` jsBundleFilePath = if (BuildConfig.DEBUG) null else FireOTA.getJSBundleFile(applicationContext),\n ` +
|
|
78
|
+
source.slice(close);
|
|
79
|
+
return { source: addKotlinImport(patched), status: "patched" };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Older templates: object : DefaultReactNativeHost(this) { … }
|
|
83
|
+
const patched = addHostOverride(source);
|
|
84
|
+
return patched ? { source: patched, status: "patched" } : { source, status: "unknown" };
|
|
85
|
+
}
|
|
86
|
+
const host = /new\s+DefaultReactNativeHost\(this\)\s*\{/.exec(source);
|
|
87
|
+
if (!host) return { source, status: "unknown" };
|
|
88
|
+
const at = host.index + host[0].length;
|
|
89
|
+
const method =
|
|
90
|
+
"\n // FireOTA: release builds load the latest verified OTA bundle.\n" +
|
|
91
|
+
" @Override\n protected String getJSBundleFile() {\n" +
|
|
92
|
+
" if (BuildConfig.DEBUG) return super.getJSBundleFile();\n" +
|
|
93
|
+
" String ota = FireOTA.getJSBundleFile(getApplicationContext());\n" +
|
|
94
|
+
" return ota != null ? ota : super.getJSBundleFile();\n }\n";
|
|
95
|
+
let patched = source.slice(0, at) + method + source.slice(at);
|
|
96
|
+
if (!patched.includes(`import ${IMPORT_SDK};`)) patched = patched.replace(/^(package .+;)$/m, `$1\n\nimport ${IMPORT_SDK};`);
|
|
97
|
+
return { source: patched, status: "patched" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const SWIFT_HELPER = `
|
|
101
|
+
|
|
102
|
+
/// FireOTA: the latest verified OTA bundle, or nil to use the one in the app binary.
|
|
103
|
+
/// Looked up at runtime, so no bridging header is needed.
|
|
104
|
+
private func fireOTABundleURL() -> URL? {
|
|
105
|
+
guard let fireOTA: AnyClass = NSClassFromString("FireOTA") else { return nil }
|
|
106
|
+
return (fireOTA as AnyObject).perform(NSSelectorFromString("bundleURL"))?.takeUnretainedValue() as? URL
|
|
107
|
+
}
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
export function patchAppDelegate(source, isSwift) {
|
|
111
|
+
if (source.includes("fireOTABundleURL") || source.includes("[FireOTA bundleURL]")) return { source, status: "already" };
|
|
112
|
+
if (isSwift) {
|
|
113
|
+
const target = 'Bundle.main.url(forResource: "main", withExtension: "jsbundle")';
|
|
114
|
+
if (!source.includes(target)) return { source, status: "unknown" };
|
|
115
|
+
return { source: source.replace(target, `fireOTABundleURL() ?? ${target}`) + SWIFT_HELPER, status: "patched" };
|
|
116
|
+
}
|
|
117
|
+
const target = '[[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]';
|
|
118
|
+
if (!source.includes(target)) return { source, status: "unknown" };
|
|
119
|
+
let patched = source.replace(target, `[FireOTA bundleURL] ?: ${target}`);
|
|
120
|
+
patched = patched.replace(/^(#import .+)$/m, `$1\n#import <FireOTA.h>`);
|
|
121
|
+
return { source: patched, status: "patched" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Wraps AppRegistry.registerComponent(name, () => App) with withFireOTA(App, fireotaConfig). */
|
|
125
|
+
export function patchEntry(source, configImport) {
|
|
126
|
+
if (source.includes("withFireOTA")) return { source, status: "already" };
|
|
127
|
+
if (source.includes("FireOTA.configure")) return { source, status: "manual-configure" };
|
|
128
|
+
const re = /AppRegistry\.registerComponent\(\s*([^,]+?)\s*,\s*\(\)\s*=>\s*([A-Za-z_$][\w$]*)\s*\)/;
|
|
129
|
+
if (!re.test(source)) return { source, status: "unknown" };
|
|
130
|
+
let patched = source.replace(re, "AppRegistry.registerComponent($1, () => withFireOTA($2, fireotaConfig))");
|
|
131
|
+
const imports = [...patched.matchAll(/^import .+$/gm)];
|
|
132
|
+
const last = imports.at(-1);
|
|
133
|
+
const lines = `import { withFireOTA } from '@fireota/react-native';\nimport fireotaConfig from '${configImport}';`;
|
|
134
|
+
patched = last ? `${patched.slice(0, last.index + last[0].length)}\n${lines}${patched.slice(last.index + last[0].length)}` : `${lines}\n${patched}`;
|
|
135
|
+
return { source: patched, status: "patched" };
|
|
136
|
+
}
|
|
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
|
+
|
|
182
|
+
function configFile({ appId, serverUrl, channel, certificate }) {
|
|
183
|
+
return `// FireOTA settings — created by \`npx fireota init\`. No secrets here; safe to commit.
|
|
184
|
+
// runtimeVersion defaults to the app's store version (Android versionName / iOS
|
|
185
|
+
// CFBundleShortVersionString): bump that version whenever native code changes.
|
|
186
|
+
|
|
187
|
+
/** @type {import('@fireota/react-native').FireOTAConfig} */
|
|
188
|
+
const fireotaConfig = {
|
|
189
|
+
appId: '${appId}',
|
|
190
|
+
serverUrl: '${serverUrl}',
|
|
191
|
+
channel: __DEV__ ? 'development' : '${channel}',
|
|
192
|
+
checkOnAppStart: true,
|
|
193
|
+
installMode: 'nextRestart', // normal updates apply on the next launch (with a "Restart" prompt)
|
|
194
|
+
mandatoryInstallMode: 'immediate',${certificate ? `\n codeSigningCertificate: \`${certificate.trim()}\`,` : ""}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export default fireotaConfig;
|
|
198
|
+
`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function ask(question) {
|
|
202
|
+
if (!process.stdin.isTTY) return "";
|
|
203
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
204
|
+
try {
|
|
205
|
+
return (await rl.question(question)).trim();
|
|
206
|
+
} finally {
|
|
207
|
+
rl.close();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function runInit(flags, { api, log, fail }) {
|
|
212
|
+
if (!existsSync("package.json") || !existsSync(join("node_modules", "react-native"))) {
|
|
213
|
+
fail("Run this from your React Native project root (react-native must be installed).");
|
|
214
|
+
}
|
|
215
|
+
if (!existsSync(join("node_modules", "@fireota", "react-native"))) {
|
|
216
|
+
fail("Install the SDK first: npm install @fireota/react-native");
|
|
217
|
+
}
|
|
218
|
+
const dry = Boolean(flags["dry-run"]);
|
|
219
|
+
const results = [];
|
|
220
|
+
const write = (path, content, label) => {
|
|
221
|
+
if (!dry) writeFileSync(path, content);
|
|
222
|
+
results.push(["✓", label]);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// 1. Settings ------------------------------------------------------------
|
|
226
|
+
let serverUrl = String(flags["server-url"] ?? process.env.FIREOTA_URL ?? "").replace(/\/+$/, "");
|
|
227
|
+
let appId = String(flags.app ?? process.env.FIREOTA_APP_ID ?? "");
|
|
228
|
+
if (!appId && process.env.FIREOTA_API_KEY && serverUrl) {
|
|
229
|
+
const me = await api("GET", "/me");
|
|
230
|
+
if (me.apps.length === 1) appId = me.apps[0].id;
|
|
231
|
+
else if (me.apps.length > 1) {
|
|
232
|
+
log("Applications in this workspace:");
|
|
233
|
+
for (const app of me.apps) log(` ${app.id} ${app.name}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (!serverUrl) serverUrl = (await ask("FireOTA URL (e.g. https://fireota.example.com): ")).replace(/\/+$/, "");
|
|
237
|
+
if (!appId) appId = await ask("App ID (Dashboard → Applications → your app): ");
|
|
238
|
+
if (!serverUrl || !appId) fail("Pass --server-url and --app (or set FIREOTA_URL and FIREOTA_APP_ID).");
|
|
239
|
+
const channel = String(flags.channel ?? "production");
|
|
240
|
+
|
|
241
|
+
const configPath = "fireota.config.js";
|
|
242
|
+
const certificatePath = join("fireota-keys", "fireota-public.pem");
|
|
243
|
+
const certificate = existsSync(certificatePath) ? readFileSync(certificatePath, "utf8") : null;
|
|
244
|
+
if (existsSync(configPath) && !flags.force) results.push(["–", `${configPath} exists (kept; --force to rewrite)`]);
|
|
245
|
+
else write(configPath, configFile({ appId, serverUrl, channel, certificate }), `${configPath} created${certificate ? " (with code signing certificate)" : ""}`);
|
|
246
|
+
|
|
247
|
+
// 2. JS entry ------------------------------------------------------------
|
|
248
|
+
const expo = isExpoProject();
|
|
249
|
+
const pkgMain = JSON.parse(readFileSync("package.json", "utf8")).main ?? "";
|
|
250
|
+
const entry = ["index.js", "index.ts", "index.tsx"].find((f) => existsSync(f));
|
|
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 {
|
|
272
|
+
const res = patchEntry(readFileSync(entry, "utf8"), "./fireota.config");
|
|
273
|
+
if (res.status === "patched") write(entry, res.source, `${entry}: root component wrapped with withFireOTA()`);
|
|
274
|
+
else if (res.status === "already") results.push(["–", `${entry} already uses withFireOTA`]);
|
|
275
|
+
else if (res.status === "manual-configure") results.push(["–", `${entry} already calls FireOTA.configure() (left as is)`]);
|
|
276
|
+
else results.push(["!", `${entry}: wrap your root component manually: withFireOTA(App, fireotaConfig)`]);
|
|
277
|
+
}
|
|
278
|
+
|
|
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 -------------------------------------------------------------
|
|
296
|
+
const mainApp = findFile(join("android", "app", "src", "main"), (n) => n === "MainApplication.kt" || n === "MainApplication.java");
|
|
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"]);
|
|
299
|
+
else {
|
|
300
|
+
const res = patchMainApplication(readFileSync(mainApp, "utf8"), mainApp.endsWith(".kt"));
|
|
301
|
+
const rel = relative(".", mainApp);
|
|
302
|
+
if (res.status === "patched") write(mainApp, res.source, `${rel}: loads the OTA bundle in release builds`);
|
|
303
|
+
else if (res.status === "already") results.push(["–", `${rel} already loads FireOTA`]);
|
|
304
|
+
else results.push(["!", `${rel}: unknown template — add FireOTA.getJSBundleFile() as in the Android setup docs`]);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 5. iOS -----------------------------------------------------------------
|
|
308
|
+
const delegate = findFile("ios", (n) => n === "AppDelegate.swift" || n === "AppDelegate.mm" || n === "AppDelegate.m", 3);
|
|
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)"]);
|
|
311
|
+
else {
|
|
312
|
+
const res = patchAppDelegate(readFileSync(delegate, "utf8"), delegate.endsWith(".swift"));
|
|
313
|
+
const rel = relative(".", delegate);
|
|
314
|
+
if (res.status === "patched") write(delegate, res.source, `${rel}: loads the OTA bundle in release builds (run pod install)`);
|
|
315
|
+
else if (res.status === "already") results.push(["–", `${rel} already loads FireOTA`]);
|
|
316
|
+
else results.push(["!", `${rel}: unknown template — return [FireOTA bundleURL] in bundleURL (see iOS setup docs)`]);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 6. package.json script + .gitignore ------------------------------------
|
|
320
|
+
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
|
321
|
+
pkg.scripts ??= {};
|
|
322
|
+
if (pkg.scripts["ota:publish"]) results.push(["–", 'package.json already has "ota:publish"']);
|
|
323
|
+
else {
|
|
324
|
+
pkg.scripts["ota:publish"] = "fireota publish --platform all";
|
|
325
|
+
write("package.json", `${JSON.stringify(pkg, null, 2)}\n`, 'package.json: added "ota:publish" script');
|
|
326
|
+
}
|
|
327
|
+
const ignore = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
|
|
328
|
+
const missing = ["*.fota", "fireota-keys/fireota-private.pem"].filter((line) => !ignore.split(/\r?\n/).includes(line));
|
|
329
|
+
if (missing.length === 0) results.push(["–", ".gitignore already covers FireOTA files"]);
|
|
330
|
+
else write(".gitignore", `${ignore.replace(/\s*$/, "\n")}\n# FireOTA\n${missing.join("\n")}\n`, `.gitignore: ignores ${missing.join(", ")}`);
|
|
331
|
+
|
|
332
|
+
const runtime = { android: storeVersion("android"), ios: storeVersion("ios") };
|
|
333
|
+
return { results, dry, appId, serverUrl, channel, runtime, expo };
|
|
334
|
+
}
|
package/lib/project.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Reads facts about the React Native project the CLI runs in. */
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** android/app/build.gradle(.kts): versionName "1.3.3" / versionName = "1.3.3" */
|
|
6
|
+
export function androidVersionName(root = ".") {
|
|
7
|
+
for (const file of ["android/app/build.gradle", "android/app/build.gradle.kts"]) {
|
|
8
|
+
const path = join(root, file);
|
|
9
|
+
if (!existsSync(path)) continue;
|
|
10
|
+
const match = /versionName\s*=?\s*["']([^"']+)["']/.exec(readFileSync(path, "utf8"));
|
|
11
|
+
if (match) return match[1];
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** ios/<App>.xcodeproj/project.pbxproj MARKETING_VERSION, or a literal CFBundleShortVersionString. */
|
|
17
|
+
export function iosMarketingVersion(root = ".") {
|
|
18
|
+
const ios = join(root, "ios");
|
|
19
|
+
if (!existsSync(ios)) return undefined;
|
|
20
|
+
for (const entry of readdirSync(ios)) {
|
|
21
|
+
if (!entry.endsWith(".xcodeproj")) continue;
|
|
22
|
+
const pbx = join(ios, entry, "project.pbxproj");
|
|
23
|
+
if (!existsSync(pbx)) continue;
|
|
24
|
+
const match = /MARKETING_VERSION\s*=\s*"?([0-9][^";]*)"?;/.exec(readFileSync(pbx, "utf8"));
|
|
25
|
+
if (match) return match[1].trim();
|
|
26
|
+
}
|
|
27
|
+
for (const entry of readdirSync(ios)) {
|
|
28
|
+
const plist = join(ios, entry, "Info.plist");
|
|
29
|
+
if (!existsSync(plist)) continue;
|
|
30
|
+
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<$]+)<\/string>/.exec(readFileSync(plist, "utf8"));
|
|
31
|
+
if (match) return match[1].trim();
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
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
|
+
|
|
58
|
+
/** The app's store version for a platform — FireOTA's default runtime version. */
|
|
59
|
+
export function storeVersion(platform, 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);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Recursively finds the first file matching a name test below a directory. */
|
|
66
|
+
export function findFile(dir, test, depth = 6) {
|
|
67
|
+
if (!existsSync(dir) || depth < 0) return undefined;
|
|
68
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
69
|
+
if (entry.name === "build" || entry.name === "Pods" || entry.name.startsWith(".")) continue;
|
|
70
|
+
const path = join(dir, entry.name);
|
|
71
|
+
if (entry.isFile() && test(entry.name)) return path;
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
const found = findFile(path, test, depth - 1);
|
|
74
|
+
if (found) return found;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|