@fireota/cli 0.2.0 → 0.3.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 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 publish --app <appId> --platform <android|ios|all> --runtime <x.y.z> --message "…"
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
@@ -14,13 +14,18 @@ import { tmpdir } from "node:os";
14
14
  import { join, resolve } from "node:path";
15
15
 
16
16
  import { detectCi } from "../lib/ci.mjs";
17
+ import { runInit } from "../lib/init.mjs";
17
18
  import { packDirectory } from "../lib/package.mjs";
19
+ import { storeVersion } from "../lib/project.mjs";
18
20
  import { generateKeys, loadPrivateKey, signUpdate } from "../lib/signing.mjs";
19
21
 
20
22
  const HELP = `FireOTA CLI
21
23
 
22
24
  Usage:
23
- fireota publish --app <appId> --platform <android|ios|all> --runtime <x.y.z> --message "What changed"
25
+ fireota init [--app <appId>] [--server-url <url>] [--channel production] [--dry-run] [--force]
26
+ One-time setup: creates fireota.config.js, wraps your root component with the
27
+ built-in update UI, and wires MainApplication / AppDelegate for release builds.
28
+ fireota publish --app <appId> --platform <android|ios|all> [--runtime <x.y.z>] --message "What changed"
24
29
  [--channel production] [--rollout 10] [--version <label>] [--mandatory] [--native ">=1.4.0"]
25
30
  [--file update.fota] [--private-key fireota-private.pem] [--json]
26
31
  Builds (unless --file), uploads to your Firebase Storage and publishes.
@@ -37,7 +42,9 @@ Usage:
37
42
  fireota releases [--app <appId>] [--json]
38
43
 
39
44
  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.
45
+ --runtime must equal the runtime version of the store build the update is for. Without
46
+ --runtime, the app's store version is used (android/app/build.gradle versionName, iOS
47
+ MARKETING_VERSION) — the same default the SDK uses.
41
48
 
42
49
  Environment: FIREOTA_URL, FIREOTA_API_KEY (required); FIREOTA_APP_ID, FIREOTA_CHANNEL,
43
50
  FIREOTA_RUNTIME, FIREOTA_SIGNING_KEY / FIREOTA_SIGNING_KEY_PATH (optional).
@@ -182,6 +189,15 @@ async function api(method, path, body, extraHeaders = {}) {
182
189
  return json.data;
183
190
  }
184
191
 
192
+ /** --runtime / FIREOTA_RUNTIME, else the app's store version for that platform. */
193
+ function runtimeFor(flags, platform) {
194
+ const explicit = flags.runtime ?? process.env.FIREOTA_RUNTIME;
195
+ if (explicit && explicit !== true) return String(explicit);
196
+ const detected = storeVersion(platform);
197
+ if (!detected) fail(`Could not read the ${platform} app version; pass --runtime <x.y.z>.`);
198
+ return detected;
199
+ }
200
+
185
201
  async function createRelease(flags, platform, extra = {}) {
186
202
  const ci = detectCi();
187
203
  const message = flags.message ?? flags.notes;
@@ -189,7 +205,7 @@ async function createRelease(flags, platform, extra = {}) {
189
205
  appId: need(flags, "app", "FIREOTA_APP_ID"),
190
206
  channel: String(flags.channel ?? process.env.FIREOTA_CHANNEL ?? "production"),
191
207
  platform,
192
- runtimeVersion: need(flags, "runtime", "FIREOTA_RUNTIME"),
208
+ runtimeVersion: runtimeFor(flags, platform),
193
209
  ...(flags.version && flags.version !== true ? { version: String(flags.version) } : {}),
194
210
  ...(message && message !== true ? { releaseNotes: String(message) } : {}),
195
211
  ...(flags.mandatory ? { mandatory: true } : {}),
@@ -231,7 +247,7 @@ async function releaseAction(releaseId, body) {
231
247
  async function publishOne(flags, platform, file, extra, privateKey) {
232
248
  const release = await createRelease(flags, platform, extra);
233
249
  if (["draft", "failed", "uploading"].includes(release.status)) {
234
- await uploadRelease(release.id, file, privateKey ? { key: privateKey, runtimeVersion: need(flags, "runtime", "FIREOTA_RUNTIME"), platform } : null);
250
+ await uploadRelease(release.id, file, privateKey ? { key: privateKey, runtimeVersion: runtimeFor(flags, platform), platform } : null);
235
251
  } else {
236
252
  log(`Bundle already uploaded (status ${release.status}); skipping upload.`);
237
253
  }
@@ -258,6 +274,25 @@ async function main() {
258
274
  bundle(parseArgs(process.argv.slice(3)).flags);
259
275
  return;
260
276
  }
277
+ if (command === "init") {
278
+ const all = parseArgs(process.argv.slice(3)).flags;
279
+ const result = await runInit(all, { api, log, fail });
280
+ console.log(result.dry ? "\nFireOTA init (dry run — nothing written):\n" : "\nFireOTA is set up:\n");
281
+ for (const [mark, text] of result.results) console.log(` ${mark} ${text}`);
282
+ const versions = [
283
+ result.runtime.android ? `Android ${result.runtime.android}` : null,
284
+ result.runtime.ios ? `iOS ${result.runtime.ios}` : null,
285
+ ].filter(Boolean);
286
+ console.log(`
287
+ Runtime version: ${versions.join(", ") || "unknown"} (your store version — bump it when native code changes).
288
+
289
+ 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" : ""}
291
+ 2. Publish an update: FIREOTA_URL=${result.serverUrl} FIREOTA_API_KEY=… npm run ota:publish -- --message "What changed"
292
+ 3. Optional: show <UpdateScreen /> from '@fireota/react-native' in your settings.`);
293
+ if (result.results.some(([mark]) => mark === "!")) console.log("\nSteps marked ! need a manual change — see https://github.com/Sujay-Dynamisch/FireOTA#readme");
294
+ return;
295
+ }
261
296
  if (command === "whoami") {
262
297
  const me = await api("GET", "/me");
263
298
  if (jsonMode) {
@@ -312,7 +347,7 @@ Next:
312
347
  // Android + iOS built from the same commit form one update group (Expo-style).
313
348
  const extra =
314
349
  platform === "all"
315
- ? { groupId: groupIdFrom(platforms.map((p) => sha256(readFileSync(files[p]))).join(":") + String(all.runtime ?? process.env.FIREOTA_RUNTIME)) }
350
+ ? { groupId: groupIdFrom(platforms.map((p) => `${sha256(readFileSync(files[p]))}@${runtimeFor(all, p)}`).join(":")) }
316
351
  : {};
317
352
  const results = [];
318
353
  for (const p of platforms) results.push(await publishOne(all, p, files[p], extra, privateKey));
package/lib/init.mjs ADDED
@@ -0,0 +1,238 @@
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 { join, relative } from "node:path";
13
+ import { createInterface } from "node:readline/promises";
14
+
15
+ import { androidVersionName, findFile, iosMarketingVersion } 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
+ export function patchMainApplication(source, isKotlin) {
43
+ if (source.includes("FireOTA.getJSBundleFile")) return { source, status: "already" };
44
+ if (isKotlin) {
45
+ // React Native 0.76+: getDefaultReactHost(context = …, packageList = …)
46
+ const call = source.indexOf("getDefaultReactHost(");
47
+ if (call !== -1) {
48
+ const open = call + "getDefaultReactHost".length;
49
+ const close = closingParen(source, open);
50
+ if (close !== -1) {
51
+ let head = source.slice(0, close).replace(/\s+$/, "");
52
+ if (!head.endsWith(",") && !head.endsWith("(")) head += ",";
53
+ const patched =
54
+ `${head}\n // FireOTA: release builds load the latest verified OTA bundle (null = bundle in the APK).\n` +
55
+ ` jsBundleFilePath = if (BuildConfig.DEBUG) null else FireOTA.getJSBundleFile(applicationContext),\n ` +
56
+ source.slice(close);
57
+ return { source: addKotlinImport(patched), status: "patched" };
58
+ }
59
+ }
60
+ // 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" };
72
+ }
73
+ const host = /new\s+DefaultReactNativeHost\(this\)\s*\{/.exec(source);
74
+ if (!host) return { source, status: "unknown" };
75
+ const at = host.index + host[0].length;
76
+ const method =
77
+ "\n // FireOTA: release builds load the latest verified OTA bundle.\n" +
78
+ " @Override\n protected String getJSBundleFile() {\n" +
79
+ " if (BuildConfig.DEBUG) return super.getJSBundleFile();\n" +
80
+ " String ota = FireOTA.getJSBundleFile(getApplicationContext());\n" +
81
+ " return ota != null ? ota : super.getJSBundleFile();\n }\n";
82
+ let patched = source.slice(0, at) + method + source.slice(at);
83
+ if (!patched.includes(`import ${IMPORT_SDK};`)) patched = patched.replace(/^(package .+;)$/m, `$1\n\nimport ${IMPORT_SDK};`);
84
+ return { source: patched, status: "patched" };
85
+ }
86
+
87
+ const SWIFT_HELPER = `
88
+
89
+ /// FireOTA: the latest verified OTA bundle, or nil to use the one in the app binary.
90
+ /// Looked up at runtime, so no bridging header is needed.
91
+ private func fireOTABundleURL() -> URL? {
92
+ guard let fireOTA: AnyClass = NSClassFromString("FireOTA") else { return nil }
93
+ return (fireOTA as AnyObject).perform(NSSelectorFromString("bundleURL"))?.takeUnretainedValue() as? URL
94
+ }
95
+ `;
96
+
97
+ export function patchAppDelegate(source, isSwift) {
98
+ if (source.includes("fireOTABundleURL") || source.includes("[FireOTA bundleURL]")) return { source, status: "already" };
99
+ if (isSwift) {
100
+ const target = 'Bundle.main.url(forResource: "main", withExtension: "jsbundle")';
101
+ if (!source.includes(target)) return { source, status: "unknown" };
102
+ return { source: source.replace(target, `fireOTABundleURL() ?? ${target}`) + SWIFT_HELPER, status: "patched" };
103
+ }
104
+ const target = '[[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]';
105
+ if (!source.includes(target)) return { source, status: "unknown" };
106
+ let patched = source.replace(target, `[FireOTA bundleURL] ?: ${target}`);
107
+ patched = patched.replace(/^(#import .+)$/m, `$1\n#import <FireOTA.h>`);
108
+ return { source: patched, status: "patched" };
109
+ }
110
+
111
+ /** Wraps AppRegistry.registerComponent(name, () => App) with withFireOTA(App, fireotaConfig). */
112
+ export function patchEntry(source, configImport) {
113
+ if (source.includes("withFireOTA")) return { source, status: "already" };
114
+ if (source.includes("FireOTA.configure")) return { source, status: "manual-configure" };
115
+ const re = /AppRegistry\.registerComponent\(\s*([^,]+?)\s*,\s*\(\)\s*=>\s*([A-Za-z_$][\w$]*)\s*\)/;
116
+ if (!re.test(source)) return { source, status: "unknown" };
117
+ let patched = source.replace(re, "AppRegistry.registerComponent($1, () => withFireOTA($2, fireotaConfig))");
118
+ const imports = [...patched.matchAll(/^import .+$/gm)];
119
+ const last = imports.at(-1);
120
+ const lines = `import { withFireOTA } from '@fireota/react-native';\nimport fireotaConfig from '${configImport}';`;
121
+ patched = last ? `${patched.slice(0, last.index + last[0].length)}\n${lines}${patched.slice(last.index + last[0].length)}` : `${lines}\n${patched}`;
122
+ return { source: patched, status: "patched" };
123
+ }
124
+
125
+ function configFile({ appId, serverUrl, channel, certificate }) {
126
+ return `// FireOTA settings — created by \`npx fireota init\`. No secrets here; safe to commit.
127
+ // runtimeVersion defaults to the app's store version (Android versionName / iOS
128
+ // CFBundleShortVersionString): bump that version whenever native code changes.
129
+
130
+ /** @type {import('@fireota/react-native').FireOTAConfig} */
131
+ const fireotaConfig = {
132
+ appId: '${appId}',
133
+ serverUrl: '${serverUrl}',
134
+ channel: __DEV__ ? 'development' : '${channel}',
135
+ checkOnAppStart: true,
136
+ installMode: 'nextRestart', // normal updates apply on the next launch (with a "Restart" prompt)
137
+ mandatoryInstallMode: 'immediate',${certificate ? `\n codeSigningCertificate: \`${certificate.trim()}\`,` : ""}
138
+ };
139
+
140
+ export default fireotaConfig;
141
+ `;
142
+ }
143
+
144
+ async function ask(question) {
145
+ if (!process.stdin.isTTY) return "";
146
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
147
+ try {
148
+ return (await rl.question(question)).trim();
149
+ } finally {
150
+ rl.close();
151
+ }
152
+ }
153
+
154
+ export async function runInit(flags, { api, log, fail }) {
155
+ if (!existsSync("package.json") || !existsSync(join("node_modules", "react-native"))) {
156
+ fail("Run this from your React Native project root (react-native must be installed).");
157
+ }
158
+ if (!existsSync(join("node_modules", "@fireota", "react-native"))) {
159
+ fail("Install the SDK first: npm install @fireota/react-native");
160
+ }
161
+ const dry = Boolean(flags["dry-run"]);
162
+ const results = [];
163
+ const write = (path, content, label) => {
164
+ if (!dry) writeFileSync(path, content);
165
+ results.push(["✓", label]);
166
+ };
167
+
168
+ // 1. Settings ------------------------------------------------------------
169
+ let serverUrl = String(flags["server-url"] ?? process.env.FIREOTA_URL ?? "").replace(/\/+$/, "");
170
+ let appId = String(flags.app ?? process.env.FIREOTA_APP_ID ?? "");
171
+ if (!appId && process.env.FIREOTA_API_KEY && serverUrl) {
172
+ const me = await api("GET", "/me");
173
+ if (me.apps.length === 1) appId = me.apps[0].id;
174
+ else if (me.apps.length > 1) {
175
+ log("Applications in this workspace:");
176
+ for (const app of me.apps) log(` ${app.id} ${app.name}`);
177
+ }
178
+ }
179
+ if (!serverUrl) serverUrl = (await ask("FireOTA URL (e.g. https://fireota.example.com): ")).replace(/\/+$/, "");
180
+ if (!appId) appId = await ask("App ID (Dashboard → Applications → your app): ");
181
+ if (!serverUrl || !appId) fail("Pass --server-url and --app (or set FIREOTA_URL and FIREOTA_APP_ID).");
182
+ const channel = String(flags.channel ?? "production");
183
+
184
+ const configPath = "fireota.config.js";
185
+ const certificatePath = join("fireota-keys", "fireota-public.pem");
186
+ const certificate = existsSync(certificatePath) ? readFileSync(certificatePath, "utf8") : null;
187
+ if (existsSync(configPath) && !flags.force) results.push(["–", `${configPath} exists (kept; --force to rewrite)`]);
188
+ else write(configPath, configFile({ appId, serverUrl, channel, certificate }), `${configPath} created${certificate ? " (with code signing certificate)" : ""}`);
189
+
190
+ // 2. JS entry ------------------------------------------------------------
191
+ 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 {
194
+ const res = patchEntry(readFileSync(entry, "utf8"), "./fireota.config");
195
+ if (res.status === "patched") write(entry, res.source, `${entry}: root component wrapped with withFireOTA()`);
196
+ else if (res.status === "already") results.push(["–", `${entry} already uses withFireOTA`]);
197
+ else if (res.status === "manual-configure") results.push(["–", `${entry} already calls FireOTA.configure() (left as is)`]);
198
+ else results.push(["!", `${entry}: wrap your root component manually: withFireOTA(App, fireotaConfig)`]);
199
+ }
200
+
201
+ // 3. Android -------------------------------------------------------------
202
+ 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"]);
204
+ else {
205
+ const res = patchMainApplication(readFileSync(mainApp, "utf8"), mainApp.endsWith(".kt"));
206
+ const rel = relative(".", mainApp);
207
+ if (res.status === "patched") write(mainApp, res.source, `${rel}: loads the OTA bundle in release builds`);
208
+ else if (res.status === "already") results.push(["–", `${rel} already loads FireOTA`]);
209
+ else results.push(["!", `${rel}: unknown template — add FireOTA.getJSBundleFile() as in the Android setup docs`]);
210
+ }
211
+
212
+ // 4. iOS -----------------------------------------------------------------
213
+ 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)"]);
215
+ else {
216
+ const res = patchAppDelegate(readFileSync(delegate, "utf8"), delegate.endsWith(".swift"));
217
+ const rel = relative(".", delegate);
218
+ if (res.status === "patched") write(delegate, res.source, `${rel}: loads the OTA bundle in release builds (run pod install)`);
219
+ else if (res.status === "already") results.push(["–", `${rel} already loads FireOTA`]);
220
+ else results.push(["!", `${rel}: unknown template — return [FireOTA bundleURL] in bundleURL (see iOS setup docs)`]);
221
+ }
222
+
223
+ // 5. package.json script + .gitignore ------------------------------------
224
+ const pkg = JSON.parse(readFileSync("package.json", "utf8"));
225
+ pkg.scripts ??= {};
226
+ if (pkg.scripts["ota:publish"]) results.push(["–", 'package.json already has "ota:publish"']);
227
+ else {
228
+ pkg.scripts["ota:publish"] = "fireota publish --platform all";
229
+ write("package.json", `${JSON.stringify(pkg, null, 2)}\n`, 'package.json: added "ota:publish" script');
230
+ }
231
+ const ignore = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
232
+ const missing = ["*.fota", "fireota-keys/fireota-private.pem"].filter((line) => !ignore.split(/\r?\n/).includes(line));
233
+ if (missing.length === 0) results.push(["–", ".gitignore already covers FireOTA files"]);
234
+ else write(".gitignore", `${ignore.replace(/\s*$/, "\n")}\n# FireOTA\n${missing.join("\n")}\n`, `.gitignore: ignores ${missing.join(", ")}`);
235
+
236
+ const runtime = { android: androidVersionName(), ios: iosMarketingVersion() };
237
+ return { results, dry, appId, serverUrl, channel, runtime };
238
+ }
@@ -0,0 +1,54 @@
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
+ /** The app's store version for a platform — FireOTA's default runtime version. */
37
+ export function storeVersion(platform, root = ".") {
38
+ return platform === "ios" ? iosMarketingVersion(root) : androidVersionName(root);
39
+ }
40
+
41
+ /** Recursively finds the first file matching a name test below a directory. */
42
+ export function findFile(dir, test, depth = 6) {
43
+ if (!existsSync(dir) || depth < 0) return undefined;
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ if (entry.name === "build" || entry.name === "Pods" || entry.name.startsWith(".")) continue;
46
+ const path = join(dir, entry.name);
47
+ if (entry.isFile() && test(entry.name)) return path;
48
+ if (entry.isDirectory()) {
49
+ const found = findFile(path, test, depth - 1);
50
+ if (found) return found;
51
+ }
52
+ }
53
+ return undefined;
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fireota/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",