@fireota/cli 0.1.1 → 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
@@ -25,53 +25,93 @@
25
25
  npm install --save-dev @fireota/cli
26
26
  ```
27
27
 
28
- Set two environment variables (create the API key in FireOTA → Settings → API keys):
28
+ Set two environment variables (an admin creates the API key in FireOTA → API keys):
29
29
 
30
30
  ```sh
31
31
  FIREOTA_URL=https://ota.example.com
32
32
  FIREOTA_API_KEY=fota_sk_live_...
33
33
  ```
34
34
 
35
- Scopes: `release.write` (create, upload), `release.publish` (publish, pause, roll back), `rollout.write` (change rollout).
35
+ Scopes: `release.write` (create, upload), `release.publish` (publish, pause, roll back), `rollout.write` (change rollout). Check a key with `npx fireota whoami`.
36
+
37
+ Optional defaults: `FIREOTA_APP_ID`, `FIREOTA_CHANNEL`, `FIREOTA_RUNTIME`.
36
38
 
37
39
  ## Publish in one command
38
40
 
39
41
  Run from your React Native project root:
40
42
 
41
43
  ```sh
42
- npx fireota publish --app YOUR_APP_ID --platform android \
43
- --version 1.3.1 --runtime 1.3.0 --channel production --rollout 10 --notes "Fix checkout crash"
44
+ npx fireota publish --app YOUR_APP_ID --platform all --runtime 1.3.0 \
45
+ --channel production --rollout 10 --message "Fix checkout crash"
44
46
  ```
45
47
 
46
- This runs the React Native bundler, compiles Hermes bytecode when Hermes is enabled, packs the images next to the bundle, creates the release, uploads it to your Firebase Storage (checksum verified) and publishes it. Retries are safe: the same inputs never create a duplicate release.
48
+ This runs the React Native bundler, compiles Hermes bytecode when Hermes is enabled, packs the images next to the bundle, creates the update, uploads it to your Firebase Storage (checksum verified) and publishes it. `--platform all` builds Android and iOS and publishes them as one update group.
49
+
50
+ - **Updates are identified by their ID**, like Expo. `--version` is only an optional label (e.g. `1.3.1-hotfix`).
51
+ - `--runtime` must equal the runtime version of the store build that should receive the update. Bump it whenever native code changes.
52
+ - Retries are safe: the same inputs never create a duplicate update, and finished steps are skipped.
53
+ - `--json` prints a machine-readable result; the exit code is 1 on any error.
54
+
55
+ ## Code signing (optional)
56
+
57
+ ```sh
58
+ npx fireota signing generate --out ./fireota-keys # fireota-private.pem stays with you
59
+ ```
47
60
 
48
- - `--version` must be new for each release on a channel.
49
- - `--runtime` must equal the runtime version of the store build that should receive it.
61
+ Paste `fireota-public.pem` into the dashboard (app → Code signing), pass the same public key to `FireOTA.configure({ codeSigningCertificate })`, and publish with `--private-key ./fireota-keys/fireota-private.pem` (in CI: the `FIREOTA_SIGNING_KEY` secret holding the PEM text, or `FIREOTA_SIGNING_KEY_PATH`). The signature covers the package's SHA-256 plus runtime and platform; the server and the app both reject updates that don't match.
50
62
 
51
63
  ## Commands
52
64
 
53
65
  ```text
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 "…"
68
+ [--channel production] [--rollout 10] [--version <label>] [--mandatory] [--native ">=1.4.0"]
69
+ [--file update.fota] [--private-key key.pem] [--json]
54
70
  fireota bundle --platform <android|ios> [--entry index.js] [--out update.fota] [--hermes auto|on|off]
55
- fireota publish --app <appId> --platform <p> --version <x.y.z> --runtime <x.y.z>
56
- [--channel production] [--rollout 10] [--notes "…"] [--mandatory] [--native ">=1.4.0"] [--file update.fota]
57
- fireota release create --app <appId> --platform <p> --version <v> --runtime <r> [--channel c]
58
- fireota release upload <releaseId> --file update.fota
71
+ fireota whoami
72
+ fireota signing generate [--out ./fireota-keys]
73
+ fireota release create --app <appId> --platform <p> --runtime <r> [--message m] [--version v] [--channel c]
74
+ fireota release upload <releaseId> --file update.fota [--private-key key.pem]
59
75
  fireota release publish <releaseId> [--rollout 100]
60
76
  fireota release rollout <releaseId> --percentage <0-100>
61
77
  fireota release pause <releaseId>
62
78
  fireota release rollback <releaseId> --reason "…" [--target <releaseId>]
63
- fireota releases [--app <appId>]
79
+ fireota releases [--app <appId>] [--json]
64
80
  ```
65
81
 
66
- ## GitHub Actions
82
+ ## CI/CD
83
+
84
+ The same command works on every CI. Commit, branch and build number are detected automatically on GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins, Azure DevOps, CircleCI, Bitrise, Buildkite, Travis, TeamCity, Codemagic and AWS CodeBuild; anywhere else set `FIREOTA_GIT_SHA`, `FIREOTA_GIT_BRANCH` and `FIREOTA_BUILD_NUMBER`. Ready-made pipeline files for each tool are in the dashboard under **CI/CD**.
85
+
86
+ GitHub Actions:
67
87
 
68
88
  ```yaml
69
- - run: npx fireota publish --app ${{ vars.FIREOTA_APP_ID }} --platform android --version 1.3.${{ github.run_number }} --runtime 1.3.0 --channel beta
89
+ - run: npx fireota publish --app ${{ vars.FIREOTA_APP_ID }} --platform android --runtime 1.3.0 --channel beta --message "${{ github.event.head_commit.message }}"
70
90
  env:
71
91
  FIREOTA_URL: ${{ vars.FIREOTA_URL }}
72
92
  FIREOTA_API_KEY: ${{ secrets.FIREOTA_API_KEY }}
73
93
  ```
74
94
 
95
+ Jenkins (declarative):
96
+
97
+ ```groovy
98
+ environment {
99
+ FIREOTA_URL = 'https://ota.example.com'
100
+ FIREOTA_API_KEY = credentials('fireota-api-key')
101
+ }
102
+ steps {
103
+ sh 'npm ci && npx fireota publish --app "$APP_ID" --platform android --runtime 1.3.0 --message "$(git log -1 --pretty=%s)"'
104
+ }
105
+ ```
106
+
107
+ Bitbucket Pipelines:
108
+
109
+ ```yaml
110
+ script:
111
+ - npm ci
112
+ - npx fireota publish --app "$FIREOTA_APP_ID" --platform android --runtime 1.3.0 --message "$(git log -1 --pretty=%s)"
113
+ ```
114
+
75
115
  ## License
76
116
 
77
117
  MIT
package/bin/fireota.mjs CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * FireOTA CLI — build update packages and manage releases from a terminal or CI.
4
- * Needs Node 18+. Configuration via environment:
5
- * FIREOTA_URL your FireOTA deployment, e.g. https://ota.example.com
6
- * FIREOTA_API_KEY an API key from Settings -> API keys (fota_sk_...)
3
+ * FireOTA CLI — build update packages and manage releases from a terminal or any CI.
4
+ * Needs Node 18+. Configuration via environment (flags win over environment):
5
+ * FIREOTA_URL your FireOTA deployment, e.g. https://ota.example.com
6
+ * FIREOTA_API_KEY an API key from Settings -> API keys (fota_sk_...)
7
+ * FIREOTA_APP_ID, FIREOTA_CHANNEL, FIREOTA_RUNTIME defaults for --app, --channel, --runtime
8
+ * FIREOTA_SIGNING_KEY (PEM text) or FIREOTA_SIGNING_KEY_PATH code signing private key
7
9
  */
8
10
  import { execFileSync } from "node:child_process";
9
11
  import { createHash } from "node:crypto";
@@ -11,24 +13,48 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
11
13
  import { tmpdir } from "node:os";
12
14
  import { join, resolve } from "node:path";
13
15
 
16
+ import { detectCi } from "../lib/ci.mjs";
17
+ import { runInit } from "../lib/init.mjs";
14
18
  import { packDirectory } from "../lib/package.mjs";
19
+ import { storeVersion } from "../lib/project.mjs";
20
+ import { generateKeys, loadPrivateKey, signUpdate } from "../lib/signing.mjs";
15
21
 
16
22
  const HELP = `FireOTA CLI
17
23
 
18
24
  Usage:
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"
29
+ [--channel production] [--rollout 10] [--version <label>] [--mandatory] [--native ">=1.4.0"]
30
+ [--file update.fota] [--private-key fireota-private.pem] [--json]
31
+ Builds (unless --file), uploads to your Firebase Storage and publishes.
32
+ --platform all publishes Android + iOS as one update group.
19
33
  fireota bundle --platform <android|ios> [--entry index.js] [--out update.fota] [--hermes auto|on|off]
20
- fireota publish --app <appId> --platform <android|ios> --version <x.y.z> --runtime <x.y.z>
21
- [--channel production] [--rollout 10] [--notes "..."] [--mandatory] [--native ">=1.4.0"]
22
- [--file update.fota] (builds the package first when --file is omitted)
23
- fireota release create --app <appId> --platform <p> --version <v> --runtime <r> [--channel c] [--notes n] [--mandatory] [--native c]
24
- fireota release upload <releaseId> --file update.fota
34
+ fireota whoami Shows the workspace, key role and apps behind FIREOTA_API_KEY.
35
+ fireota signing generate [--out ./fireota-keys] Creates an RSA key pair for code signing.
36
+ fireota release create --app <appId> --platform <p> --runtime <r> [--message m] [--version v] [--channel c]
37
+ fireota release upload <releaseId> --file update.fota [--private-key key.pem]
25
38
  fireota release publish <releaseId> [--rollout 100]
26
39
  fireota release rollout <releaseId> --percentage <0-100>
27
40
  fireota release pause <releaseId>
28
41
  fireota release rollback <releaseId> --reason "..." [--target <releaseId>]
29
- fireota releases [--app <appId>]
42
+ fireota releases [--app <appId>] [--json]
30
43
 
31
- Environment: FIREOTA_URL, FIREOTA_API_KEY. Run from your React Native project root.`;
44
+ Updates are identified by their ID (like Expo): --version is only an optional label, and
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.
48
+
49
+ Environment: FIREOTA_URL, FIREOTA_API_KEY (required); FIREOTA_APP_ID, FIREOTA_CHANNEL,
50
+ FIREOTA_RUNTIME, FIREOTA_SIGNING_KEY / FIREOTA_SIGNING_KEY_PATH (optional).
51
+ Works on GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps, CircleCI, Bitrise and any
52
+ other CI: commit, branch and build number are detected automatically.
53
+ Exit codes: 0 success, 1 error.`;
54
+
55
+ let jsonMode = false;
56
+ /** Human progress goes to stderr in --json mode so stdout stays machine-readable. */
57
+ const log = (...args) => (jsonMode ? console.error(...args) : console.log(...args));
32
58
 
33
59
  function parseArgs(argv) {
34
60
  const positional = [];
@@ -49,13 +75,14 @@ function parseArgs(argv) {
49
75
  }
50
76
 
51
77
  function fail(message) {
78
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: message }));
52
79
  console.error(`fireota: ${message}`);
53
80
  process.exit(1);
54
81
  }
55
82
 
56
- function need(flags, key) {
57
- const value = flags[key];
58
- if (value === undefined || value === true) fail(`--${key} is required`);
83
+ function need(flags, key, envName) {
84
+ const value = flags[key] ?? (envName ? process.env[envName] : undefined);
85
+ if (value === undefined || value === true || value === "") fail(`--${key} is required${envName ? ` (or set ${envName})` : ""}`);
59
86
  return String(value);
60
87
  }
61
88
 
@@ -63,6 +90,12 @@ function sha256(buffer) {
63
90
  return createHash("sha256").update(buffer).digest("hex");
64
91
  }
65
92
 
93
+ /** Deterministic RFC-4122-shaped id, so a retried CI job reuses the same update group. */
94
+ function groupIdFrom(seed) {
95
+ const h = sha256(Buffer.from(seed));
96
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
97
+ }
98
+
66
99
  // ---------- packaging ----------
67
100
 
68
101
  function hermesEnabled(platform, mode) {
@@ -88,8 +121,8 @@ function hermesc() {
88
121
  }
89
122
 
90
123
  /** Runs the React Native bundler (+ Hermes) and packs bundle and assets into one update file. */
91
- function bundle(flags) {
92
- const platform = need(flags, "platform");
124
+ function bundle(flags, platformOverride) {
125
+ const platform = platformOverride ?? need(flags, "platform");
93
126
  if (!["android", "ios"].includes(platform)) fail("--platform must be android or ios");
94
127
  if (!existsSync("package.json") || !existsSync(join("node_modules", "react-native"))) {
95
128
  fail("Run this from your React Native project root (react-native must be installed).");
@@ -100,25 +133,27 @@ function bundle(flags) {
100
133
  const out = join(work, "package");
101
134
  try {
102
135
  const jsOut = join(work, "bundle.js");
103
- console.log(`Bundling ${entry} for ${platform}…`);
136
+ log(`Bundling ${entry} for ${platform}…`);
104
137
  // Run the project's own React Native CLI through Node: no shell, same on every OS.
105
138
  execFileSync(
106
139
  process.execPath,
107
140
  [join("node_modules", "react-native", "cli.js"), "bundle", "--platform", platform, "--dev", "false", "--minify", "false", "--entry-file", entry, "--bundle-output", jsOut, "--assets-dest", out],
108
- { stdio: "inherit" },
141
+ { stdio: jsonMode ? ["ignore", "ignore", "inherit"] : "inherit" },
109
142
  );
110
143
  // The bundler only creates the assets folder when the app has images.
111
144
  mkdirSync(out, { recursive: true });
112
145
  if (hermesEnabled(platform, String(flags.hermes ?? "auto"))) {
113
- console.log("Compiling to Hermes bytecode…");
114
- execFileSync(hermesc(), ["-O", "-w", "-emit-binary", "-out", join(out, bundleName), jsOut], { stdio: "inherit" });
146
+ log("Compiling to Hermes bytecode…");
147
+ execFileSync(hermesc(), ["-O", "-w", "-emit-binary", "-out", join(out, bundleName), jsOut], {
148
+ stdio: jsonMode ? ["ignore", "ignore", "inherit"] : "inherit",
149
+ });
115
150
  } else {
116
151
  writeFileSync(join(out, bundleName), readFileSync(jsOut));
117
152
  }
118
153
  const packed = packDirectory(out);
119
- const target = resolve(String(flags.out ?? `fireota-${platform}.fota`));
154
+ const target = resolve(String(flags.out && !platformOverride ? flags.out : `fireota-${platform}.fota`));
120
155
  writeFileSync(target, packed);
121
- console.log(`Package: ${target}\nSize: ${(packed.length / 1024 / 1024).toFixed(2)} MB\nSHA-256: ${sha256(packed)}`);
156
+ log(`Package: ${target}\nSize: ${(packed.length / 1024 / 1024).toFixed(2)} MB\nSHA-256: ${sha256(packed)}`);
122
157
  return target;
123
158
  } finally {
124
159
  rmSync(work, { recursive: true, force: true });
@@ -132,13 +167,18 @@ async function api(method, path, body, extraHeaders = {}) {
132
167
  const key = process.env.FIREOTA_API_KEY;
133
168
  if (!base) fail("Set FIREOTA_URL to your FireOTA deployment URL.");
134
169
  if (!key) fail("Set FIREOTA_API_KEY (Settings -> API keys).");
135
- const headers = { authorization: `Bearer ${key}`, ...extraHeaders };
170
+ const headers = { authorization: `Bearer ${key}`, "user-agent": "fireota-cli", ...extraHeaders };
136
171
  let payload = body;
137
172
  if (body && !(body instanceof FormData)) {
138
173
  headers["content-type"] = "application/json";
139
174
  payload = JSON.stringify(body);
140
175
  }
141
- const response = await fetch(`${base.replace(/\/+$/, "")}/api/public/v1${path}`, { method, headers, body: payload });
176
+ let response;
177
+ try {
178
+ response = await fetch(`${base.replace(/\/+$/, "")}/api/public/v1${path}`, { method, headers, body: payload });
179
+ } catch (error) {
180
+ fail(`Could not reach ${base}: ${error instanceof Error ? error.message : error}`);
181
+ }
142
182
  let json;
143
183
  try {
144
184
  json = await response.json();
@@ -149,34 +189,53 @@ async function api(method, path, body, extraHeaders = {}) {
149
189
  return json.data;
150
190
  }
151
191
 
152
- async function createRelease(flags) {
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
+
201
+ async function createRelease(flags, platform, extra = {}) {
202
+ const ci = detectCi();
203
+ const message = flags.message ?? flags.notes;
153
204
  const body = {
154
- appId: need(flags, "app"),
155
- channel: String(flags.channel ?? "production"),
156
- platform: need(flags, "platform"),
157
- version: need(flags, "version"),
158
- runtimeVersion: need(flags, "runtime"),
159
- ...(flags.notes ? { releaseNotes: String(flags.notes) } : {}),
205
+ appId: need(flags, "app", "FIREOTA_APP_ID"),
206
+ channel: String(flags.channel ?? process.env.FIREOTA_CHANNEL ?? "production"),
207
+ platform,
208
+ runtimeVersion: runtimeFor(flags, platform),
209
+ ...(flags.version && flags.version !== true ? { version: String(flags.version) } : {}),
210
+ ...(message && message !== true ? { releaseNotes: String(message) } : {}),
160
211
  ...(flags.mandatory ? { mandatory: true } : {}),
161
212
  ...(flags.native ? { nativeVersionConstraint: String(flags.native) } : {}),
162
- ...(process.env.GITHUB_SHA ? { gitCommitSha: process.env.GITHUB_SHA } : {}),
163
- ...(process.env.GITHUB_REF_NAME ? { gitBranch: process.env.GITHUB_REF_NAME } : {}),
213
+ ...(ci.gitCommitSha ? { gitCommitSha: ci.gitCommitSha.slice(0, 64) } : {}),
214
+ ...(ci.gitBranch ? { gitBranch: ci.gitBranch.slice(0, 120) } : {}),
215
+ ...(ci.buildNumber ? { buildNumber: String(ci.buildNumber).slice(0, 40) } : {}),
216
+ ...extra,
164
217
  };
165
218
  // Same inputs -> same release, so a retried CI job never creates duplicates.
166
219
  const idempotencyKey = sha256(Buffer.from(JSON.stringify(body)));
167
220
  const release = await api("POST", "/releases", body, { "idempotency-key": idempotencyKey });
168
- console.log(`Release #${release.number} (${release.id}) — ${release.status}`);
221
+ log(`Update #${release.number} (${release.id}) — ${release.status} [${platform}, ${ci.provider}]`);
169
222
  return release;
170
223
  }
171
224
 
172
- async function uploadRelease(releaseId, file) {
225
+ async function uploadRelease(releaseId, file, signing) {
173
226
  if (!existsSync(file)) fail(`File not found: ${file}`);
174
227
  const bytes = readFileSync(file);
228
+ const hash = sha256(bytes);
175
229
  const form = new FormData();
176
230
  form.append("bundle", new Blob([bytes], { type: "application/octet-stream" }), "update.fota");
177
- console.log(`Uploading ${(bytes.length / 1024 / 1024).toFixed(2)} MB…`);
178
- const result = await api("POST", `/releases/${releaseId}/bundle`, form, { "x-bundle-sha256": sha256(bytes) });
179
- console.log(`Verified in your Firebase Storage. SHA-256 ${result.checksum}`);
231
+ const headers = { "x-bundle-sha256": hash };
232
+ if (signing?.key) {
233
+ headers["x-fireota-signature"] = signUpdate(signing.key, { runtimeVersion: signing.runtimeVersion, platform: signing.platform, sha256: hash });
234
+ log("Signed with your code signing key.");
235
+ }
236
+ log(`Uploading ${(bytes.length / 1024 / 1024).toFixed(2)} MB…`);
237
+ const result = await api("POST", `/releases/${releaseId}/bundle`, form, headers);
238
+ log(`Verified in your Firebase Storage. SHA-256 ${result.checksum}`);
180
239
  return result;
181
240
  }
182
241
 
@@ -184,9 +243,28 @@ async function releaseAction(releaseId, body) {
184
243
  return api("POST", `/releases/${releaseId}/publish`, body);
185
244
  }
186
245
 
246
+ /** Create → upload → publish, skipping steps a retried CI job already completed. */
247
+ async function publishOne(flags, platform, file, extra, privateKey) {
248
+ const release = await createRelease(flags, platform, extra);
249
+ if (["draft", "failed", "uploading"].includes(release.status)) {
250
+ await uploadRelease(release.id, file, privateKey ? { key: privateKey, runtimeVersion: runtimeFor(flags, platform), platform } : null);
251
+ } else {
252
+ log(`Bundle already uploaded (status ${release.status}); skipping upload.`);
253
+ }
254
+ const rollout = Number(flags.rollout ?? 100);
255
+ if (release.status === "published") {
256
+ log("Already published; nothing to do.");
257
+ return { id: release.id, number: release.number, platform, rollout: null, status: "published" };
258
+ }
259
+ const result = await releaseAction(release.id, { action: "publish", rolloutPercentage: rollout });
260
+ log(`Published update #${release.number} (${platform}) at ${result.rollout}%.`);
261
+ return { id: release.id, number: release.number, platform, rollout: result.rollout, status: "published" };
262
+ }
263
+
187
264
  async function main() {
188
265
  const [command, sub, ...rest] = process.argv.slice(2);
189
266
  const { positional, flags } = parseArgs([sub, ...rest].filter((v) => v !== undefined));
267
+ jsonMode = Boolean(flags.json);
190
268
 
191
269
  if (!command || command === "help" || command === "--help" || flags.help) {
192
270
  console.log(HELP);
@@ -196,53 +274,135 @@ async function main() {
196
274
  bundle(parseArgs(process.argv.slice(3)).flags);
197
275
  return;
198
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
+ }
296
+ if (command === "whoami") {
297
+ const me = await api("GET", "/me");
298
+ if (jsonMode) {
299
+ console.log(JSON.stringify({ ok: true, ...me }));
300
+ return;
301
+ }
302
+ console.log(`Workspace: ${me.organization.name} (${me.organization.slug})\nKey: ${me.key.label} — acts as ${me.key.role}\nApps:`);
303
+ for (const app of me.apps) {
304
+ console.log(` ${app.name}\t${app.id}\truntime ${app.runtimeVersion}${app.requiresSignedUpdates ? "\tsigned updates required" : ""}`);
305
+ }
306
+ return;
307
+ }
308
+ if (command === "signing") {
309
+ if (positional[0] !== "generate") fail("usage: fireota signing generate [--out ./fireota-keys]");
310
+ const keys = generateKeys(flags.out && flags.out !== true ? String(flags.out) : undefined);
311
+ console.log(`Created ${keys.privatePath} (keep secret — never commit it)\nCreated ${keys.publicPath}
312
+
313
+ Next:
314
+ 1. Dashboard → Applications → your app → Code signing: paste ${keys.publicPath}
315
+ 2. In the app: FireOTA.configure({ …, codeSigningCertificate: <contents of fireota-public.pem> })
316
+ 3. Publish with --private-key ${keys.privatePath} (in CI: store the file as the FIREOTA_SIGNING_KEY secret)`);
317
+ return;
318
+ }
199
319
  if (command === "releases") {
200
- const query = flags.app ? `?appId=${encodeURIComponent(String(flags.app))}` : "";
320
+ const app = flags.app ?? process.env.FIREOTA_APP_ID;
321
+ const query = app ? `?appId=${encodeURIComponent(String(app))}` : "";
201
322
  const rows = await api("GET", `/releases${query}`);
323
+ if (jsonMode) {
324
+ console.log(JSON.stringify({ ok: true, releases: rows }));
325
+ return;
326
+ }
202
327
  for (const r of rows) {
203
- console.log(`#${r.number}\t${r.platform}\t${r.version}\t${r.status}\t${r.rollout_percentage}%\t${r.id}`);
328
+ console.log(`#${r.number}\t${r.id.slice(0, 8)}\t${r.platform}\truntime ${r.runtime_version}\t${r.status}\t${r.rollout_percentage}%\t${r.version || "-"}\t${(r.release_notes || "").split("\n")[0]}`);
204
329
  }
205
330
  return;
206
331
  }
207
332
  if (command === "publish") {
208
333
  const all = parseArgs(process.argv.slice(3)).flags;
209
- const file = all.file ? String(all.file) : bundle(all);
210
- const release = await createRelease(all);
211
- await uploadRelease(release.id, file);
212
- const result = await releaseAction(release.id, { action: "publish", rolloutPercentage: Number(all.rollout ?? 100) });
213
- console.log(`Published #${release.number} at ${result.rollout}%.`);
334
+ jsonMode = Boolean(all.json);
335
+ const platform = need(all, "platform");
336
+ if (!["android", "ios", "all"].includes(platform)) fail("--platform must be android, ios or all");
337
+ if (platform === "all" && all.file) fail("--file cannot be combined with --platform all (each platform needs its own package).");
338
+ let privateKey = null;
339
+ try {
340
+ privateKey = loadPrivateKey(all["private-key"] && all["private-key"] !== true ? String(all["private-key"]) : undefined);
341
+ } catch (error) {
342
+ fail(`Could not read the signing key: ${error instanceof Error ? error.message : error}`);
343
+ }
344
+
345
+ const platforms = platform === "all" ? ["android", "ios"] : [platform];
346
+ const files = Object.fromEntries(platforms.map((p) => [p, all.file ? String(all.file) : bundle(all, p)]));
347
+ // Android + iOS built from the same commit form one update group (Expo-style).
348
+ const extra =
349
+ platform === "all"
350
+ ? { groupId: groupIdFrom(platforms.map((p) => `${sha256(readFileSync(files[p]))}@${runtimeFor(all, p)}`).join(":")) }
351
+ : {};
352
+ const results = [];
353
+ for (const p of platforms) results.push(await publishOne(all, p, files[p], extra, privateKey));
354
+ if (jsonMode) console.log(JSON.stringify({ ok: true, groupId: extra.groupId ?? null, updates: results }));
214
355
  return;
215
356
  }
216
357
  if (command === "release") {
217
358
  const id = positional[1];
218
359
  switch (positional[0]) {
219
- case "create":
220
- await createRelease(flags);
360
+ case "create": {
361
+ const r = await createRelease(flags, need(flags, "platform"));
362
+ if (jsonMode) console.log(JSON.stringify({ ok: true, ...r }));
221
363
  return;
222
- case "upload":
223
- await uploadRelease(id ?? fail("release id required"), need(flags, "file"));
364
+ }
365
+ case "upload": {
366
+ let privateKey = null;
367
+ try {
368
+ privateKey = loadPrivateKey(flags["private-key"] && flags["private-key"] !== true ? String(flags["private-key"]) : undefined);
369
+ } catch (error) {
370
+ fail(`Could not read the signing key: ${error instanceof Error ? error.message : error}`);
371
+ }
372
+ let signing = null;
373
+ if (privateKey) {
374
+ const list = await api("GET", "/releases?limit=200");
375
+ const target = list.find((r) => r.id === id);
376
+ if (!target) fail("Release not found (needed to sign it).");
377
+ signing = { key: privateKey, runtimeVersion: target.runtime_version, platform: target.platform };
378
+ }
379
+ const r = await uploadRelease(id ?? fail("release id required"), need(flags, "file"), signing);
380
+ if (jsonMode) console.log(JSON.stringify({ ok: true, ...r }));
224
381
  return;
382
+ }
225
383
  case "publish": {
226
384
  const r = await releaseAction(id ?? fail("release id required"), { action: "publish", rolloutPercentage: Number(flags.rollout ?? 100) });
227
- console.log(`Published at ${r.rollout}%.`);
385
+ jsonMode ? console.log(JSON.stringify({ ok: true, ...r })) : console.log(`Published at ${r.rollout}%.`);
228
386
  return;
229
387
  }
230
388
  case "rollout": {
231
389
  const r = await releaseAction(id ?? fail("release id required"), { action: "rollout", rolloutPercentage: Number(need(flags, "percentage")) });
232
- console.log(`Rollout set to ${r.rollout}%.`);
390
+ jsonMode ? console.log(JSON.stringify({ ok: true, ...r })) : console.log(`Rollout set to ${r.rollout}%.`);
233
391
  return;
234
392
  }
235
- case "pause":
236
- await releaseAction(id ?? fail("release id required"), { action: "pause" });
237
- console.log("Paused.");
393
+ case "pause": {
394
+ const r = await releaseAction(id ?? fail("release id required"), { action: "pause" });
395
+ jsonMode ? console.log(JSON.stringify({ ok: true, ...r })) : console.log("Paused.");
238
396
  return;
397
+ }
239
398
  case "rollback": {
240
399
  const r = await releaseAction(id ?? fail("release id required"), {
241
400
  action: "rollback",
242
401
  reason: need(flags, "reason"),
243
402
  ...(flags.target ? { targetReleaseId: String(flags.target) } : {}),
244
403
  });
245
- console.log(r.restoredReleaseId ? `Rolled back; now serving ${r.restoredReleaseId}.` : "Rolled back; channel has no active release.");
404
+ if (jsonMode) console.log(JSON.stringify({ ok: true, ...r }));
405
+ else console.log(r.restoredReleaseId ? `Rolled back; now serving ${r.restoredReleaseId}.` : "Rolled back; channel has no active release.");
246
406
  return;
247
407
  }
248
408
  default:
package/lib/ci.mjs ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Detects the CI provider and the commit/branch/build being released, so `fireota publish`
3
+ * records the same metadata on GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps,
4
+ * CircleCI, Bitrise, Buildkite, Travis, TeamCity, Codemagic, AWS CodeBuild — or any other
5
+ * tool through the FIREOTA_GIT_SHA / FIREOTA_GIT_BRANCH / FIREOTA_BUILD_NUMBER variables.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+
9
+ const env = process.env;
10
+
11
+ const PROVIDERS = [
12
+ { name: "GitHub Actions", when: () => env.GITHUB_ACTIONS === "true", sha: "GITHUB_SHA", branch: "GITHUB_REF_NAME", build: "GITHUB_RUN_NUMBER" },
13
+ { name: "GitLab CI", when: () => env.GITLAB_CI === "true", sha: "CI_COMMIT_SHA", branch: "CI_COMMIT_REF_NAME", build: "CI_PIPELINE_IID" },
14
+ { name: "Bitbucket Pipelines", when: () => Boolean(env.BITBUCKET_BUILD_NUMBER), sha: "BITBUCKET_COMMIT", branch: "BITBUCKET_BRANCH", build: "BITBUCKET_BUILD_NUMBER" },
15
+ { name: "Jenkins", when: () => Boolean(env.JENKINS_URL), sha: "GIT_COMMIT", branch: ["BRANCH_NAME", "GIT_BRANCH"], build: "BUILD_NUMBER" },
16
+ { name: "Azure Pipelines", when: () => env.TF_BUILD === "True", sha: "BUILD_SOURCEVERSION", branch: "BUILD_SOURCEBRANCHNAME", build: "BUILD_BUILDNUMBER" },
17
+ { name: "CircleCI", when: () => env.CIRCLECI === "true", sha: "CIRCLE_SHA1", branch: "CIRCLE_BRANCH", build: "CIRCLE_BUILD_NUM" },
18
+ { name: "Bitrise", when: () => env.BITRISE_IO === "true", sha: "BITRISE_GIT_COMMIT", branch: "BITRISE_GIT_BRANCH", build: "BITRISE_BUILD_NUMBER" },
19
+ { name: "Buildkite", when: () => env.BUILDKITE === "true", sha: "BUILDKITE_COMMIT", branch: "BUILDKITE_BRANCH", build: "BUILDKITE_BUILD_NUMBER" },
20
+ { name: "Travis CI", when: () => env.TRAVIS === "true", sha: "TRAVIS_COMMIT", branch: "TRAVIS_BRANCH", build: "TRAVIS_BUILD_NUMBER" },
21
+ { name: "TeamCity", when: () => Boolean(env.TEAMCITY_VERSION), sha: "BUILD_VCS_NUMBER", branch: "TEAMCITY_BUILD_BRANCH", build: "BUILD_NUMBER" },
22
+ { name: "Codemagic", when: () => env.CM_BUILD_ID !== undefined, sha: "CM_COMMIT", branch: "CM_BRANCH", build: "BUILD_NUMBER" },
23
+ { name: "AWS CodeBuild", when: () => Boolean(env.CODEBUILD_BUILD_ID), sha: "CODEBUILD_RESOLVED_SOURCE_VERSION", branch: "CODEBUILD_WEBHOOK_HEAD_REF", build: "CODEBUILD_BUILD_NUMBER" },
24
+ ];
25
+
26
+ function pick(keys) {
27
+ for (const key of [keys].flat()) if (env[key]) return env[key];
28
+ return undefined;
29
+ }
30
+
31
+ function git(args) {
32
+ try {
33
+ return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || undefined;
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ }
38
+
39
+ export function detectCi() {
40
+ const provider = PROVIDERS.find((p) => p.when());
41
+ const ci = Boolean(provider) || env.CI === "true" || env.CI === "1";
42
+ const branch = (env.FIREOTA_GIT_BRANCH ?? (provider ? pick(provider.branch) : undefined) ?? git(["rev-parse", "--abbrev-ref", "HEAD"]))?.replace(
43
+ /^(refs\/heads\/|origin\/)/,
44
+ "",
45
+ );
46
+ return {
47
+ ci,
48
+ provider: provider?.name ?? (ci ? "CI" : "local"),
49
+ gitCommitSha: env.FIREOTA_GIT_SHA ?? (provider ? pick(provider.sha) : undefined) ?? git(["rev-parse", "HEAD"]),
50
+ gitBranch: branch && branch !== "HEAD" ? branch : undefined,
51
+ buildNumber: env.FIREOTA_BUILD_NUMBER ?? (provider ? pick(provider.build) : undefined),
52
+ };
53
+ }
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
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Code signing (optional, per app). The private key stays on this machine or in your CI's
3
+ * secret store; FireOTA and the app only ever see the public key.
4
+ *
5
+ * Signed message — identical in the server and the SDK:
6
+ * fireota-update-v1\n<runtimeVersion>\n<platform>\n<sha256 of the .fota package, hex>
7
+ */
8
+ import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+
12
+ export function signatureMessage({ runtimeVersion, platform, sha256 }) {
13
+ return `fireota-update-v1\n${runtimeVersion}\n${platform}\n${sha256.toLowerCase()}`;
14
+ }
15
+
16
+ /** --private-key <file>, or FIREOTA_SIGNING_KEY (PEM text) / FIREOTA_SIGNING_KEY_PATH in CI. */
17
+ export function loadPrivateKey(flagPath) {
18
+ const path = flagPath ?? process.env.FIREOTA_SIGNING_KEY_PATH;
19
+ const pem = path ? readFileSync(resolve(String(path)), "utf8") : process.env.FIREOTA_SIGNING_KEY;
20
+ if (!pem) return null;
21
+ // CI secret stores often turn newlines into literal "\n".
22
+ const key = createPrivateKey(pem.includes("\\n") ? pem.replace(/\\n/g, "\n") : pem);
23
+ if (key.asymmetricKeyType !== "rsa") throw new Error("The signing key must be an RSA private key.");
24
+ return key;
25
+ }
26
+
27
+ export function signUpdate(privateKey, input) {
28
+ return sign("sha256", Buffer.from(signatureMessage(input), "utf8"), privateKey).toString("base64");
29
+ }
30
+
31
+ export function generateKeys(outDir) {
32
+ const dir = resolve(outDir ?? "fireota-keys");
33
+ const privatePath = join(dir, "fireota-private.pem");
34
+ const publicPath = join(dir, "fireota-public.pem");
35
+ if (existsSync(privatePath) || existsSync(publicPath)) {
36
+ throw new Error(`${dir} already contains FireOTA keys. Refusing to overwrite them.`);
37
+ }
38
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", {
39
+ modulusLength: 2048,
40
+ publicKeyEncoding: { type: "spki", format: "pem" },
41
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
42
+ });
43
+ mkdirSync(dir, { recursive: true });
44
+ writeFileSync(privatePath, privateKey, { mode: 0o600 });
45
+ writeFileSync(publicPath, publicKey);
46
+ writeFileSync(join(dir, ".gitignore"), "fireota-private.pem\n");
47
+ return { dir, privatePath, publicPath, publicKey };
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fireota/cli",
3
- "version": "0.1.1",
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",