@fireota/cli 0.4.0 → 0.6.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 +121 -117
- package/bin/fireota.mjs +536 -431
- package/lib/ci.mjs +53 -53
- package/lib/init.mjs +334 -334
- package/lib/project.mjs +78 -78
- package/lib/signing.mjs +48 -48
- package/package.json +40 -40
package/lib/init.mjs
CHANGED
|
@@ -1,334 +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
|
-
}
|
|
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
|
+
}
|