@fireota/cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/fireota.mjs CHANGED
@@ -1,433 +1,527 @@
1
- #!/usr/bin/env node
2
- /**
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
9
- */
10
- import { execFileSync } from "node:child_process";
11
- import { createHash } from "node:crypto";
12
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
- import { tmpdir } from "node:os";
14
- import { createRequire } from "node:module";
15
- import { join, relative, resolve } from "node:path";
16
-
17
- import { detectCi } from "../lib/ci.mjs";
18
- import { runInit } from "../lib/init.mjs";
19
- import { packDirectory } from "../lib/package.mjs";
20
- import { isExpoProject, readExpoAppJson, storeVersion } from "../lib/project.mjs";
21
- import { generateKeys, loadPrivateKey, signUpdate } from "../lib/signing.mjs";
22
-
23
- const HELP = `FireOTA CLI
24
-
25
- Usage:
26
- fireota init [--app <appId>] [--server-url <url>] [--channel production] [--dry-run] [--force]
27
- One-time setup: creates fireota.config.js, wraps your root component with the
28
- built-in update UI, and wires MainApplication / AppDelegate for release builds.
29
- fireota publish --app <appId> --platform <android|ios|all> [--runtime <x.y.z>] --message "What changed"
30
- [--channel production] [--rollout 10] [--version <label>] [--mandatory] [--native ">=1.4.0"]
31
- [--file update.fota] [--private-key fireota-private.pem] [--json]
32
- Builds (unless --file), uploads to your Firebase Storage and publishes.
33
- --platform all publishes Android + iOS as one update group.
34
- fireota bundle --platform <android|ios> [--entry index.js] [--out update.fota] [--hermes auto|on|off]
35
- fireota whoami Shows the workspace, key role and apps behind FIREOTA_API_KEY.
36
- fireota signing generate [--out ./fireota-keys] Creates an RSA key pair for code signing.
37
- fireota release create --app <appId> --platform <p> --runtime <r> [--message m] [--version v] [--channel c]
38
- fireota release upload <releaseId> --file update.fota [--private-key key.pem]
39
- fireota release publish <releaseId> [--rollout 100]
40
- fireota release rollout <releaseId> --percentage <0-100>
41
- fireota release pause <releaseId>
42
- fireota release rollback <releaseId> --reason "..." [--target <releaseId>]
43
- fireota releases [--app <appId>] [--json]
44
-
45
- Updates are identified by their ID (like Expo): --version is only an optional label, and
46
- --runtime must equal the runtime version of the store build the update is for. Without
47
- --runtime, the app's store version is used (android/app/build.gradle versionName, iOS
48
- MARKETING_VERSION) — the same default the SDK uses.
49
-
50
- Environment: FIREOTA_URL, FIREOTA_API_KEY (required); FIREOTA_APP_ID, FIREOTA_CHANNEL,
51
- FIREOTA_RUNTIME, FIREOTA_SIGNING_KEY / FIREOTA_SIGNING_KEY_PATH (optional).
52
- Works on GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps, CircleCI, Bitrise and any
53
- other CI: commit, branch and build number are detected automatically.
54
- Exit codes: 0 success, 1 error.`;
55
-
56
- let jsonMode = false;
57
- /** Human progress goes to stderr in --json mode so stdout stays machine-readable. */
58
- const log = (...args) => (jsonMode ? console.error(...args) : console.log(...args));
59
-
60
- function parseArgs(argv) {
61
- const positional = [];
62
- const flags = {};
63
- for (let i = 0; i < argv.length; i += 1) {
64
- const arg = argv[i];
65
- if (arg.startsWith("--")) {
66
- const key = arg.slice(2);
67
- const next = argv[i + 1];
68
- if (next === undefined || next.startsWith("--")) flags[key] = true;
69
- else {
70
- flags[key] = next;
71
- i += 1;
72
- }
73
- } else positional.push(arg);
74
- }
75
- return { positional, flags };
76
- }
77
-
78
- function fail(message) {
79
- if (jsonMode) console.log(JSON.stringify({ ok: false, error: message }));
80
- console.error(`fireota: ${message}`);
81
- process.exit(1);
82
- }
83
-
84
- function need(flags, key, envName) {
85
- const value = flags[key] ?? (envName ? process.env[envName] : undefined);
86
- if (value === undefined || value === true || value === "") fail(`--${key} is required${envName ? ` (or set ${envName})` : ""}`);
87
- return String(value);
88
- }
89
-
90
- function sha256(buffer) {
91
- return createHash("sha256").update(buffer).digest("hex");
92
- }
93
-
94
- /** Deterministic RFC-4122-shaped id, so a retried CI job reuses the same update group. */
95
- function groupIdFrom(seed) {
96
- const h = sha256(Buffer.from(seed));
97
- return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
98
- }
99
-
100
- // ---------- packaging ----------
101
-
102
- function hermesEnabled(platform, mode) {
103
- if (mode === "on") return true;
104
- if (mode === "off") return false;
105
- // Expo without android/ or ios/ yet: the engine is set in app.json (Hermes unless "jsEngine": "jsc").
106
- if (isExpoProject() && !existsSync(platform === "android" ? "android" : "ios")) {
107
- const expo = readExpoAppJson() ?? {};
108
- return (expo[platform]?.jsEngine ?? expo.jsEngine ?? "hermes") !== "jsc";
109
- }
110
- if (platform === "android") {
111
- const props = existsSync("android/gradle.properties") ? readFileSync("android/gradle.properties", "utf8") : "";
112
- return !/^\s*hermesEnabled\s*=\s*false/m.test(props);
113
- }
114
- const podfile = existsSync("ios/Podfile") ? readFileSync("ios/Podfile", "utf8") : "";
115
- return !/:hermes_enabled\s*=>\s*false/.test(podfile);
116
- }
117
-
118
- function hermesc() {
119
- const dir = { win32: "win64-bin", darwin: "osx-bin", linux: "linux64-bin" }[process.platform];
120
- const candidates = [
121
- join("node_modules", "react-native", "sdks", "hermesc", dir ?? "", process.platform === "win32" ? "hermesc.exe" : "hermesc"),
122
- join("node_modules", "hermes-compiler", "hermesc", dir ?? "", process.platform === "win32" ? "hermesc.exe" : "hermesc"),
123
- ];
124
- const found = candidates.find((c) => existsSync(c));
125
- if (!found) fail("Hermes is enabled but hermesc was not found in node_modules. Run npm install, or pass --hermes off.");
126
- return found;
127
- }
128
-
129
- /** Runs the React Native bundler (+ Hermes) and packs bundle and assets into one update file. */
130
- function bundle(flags, platformOverride) {
131
- const platform = platformOverride ?? need(flags, "platform");
132
- if (!["android", "ios"].includes(platform)) fail("--platform must be android or ios");
133
- if (!existsSync("package.json") || !existsSync(join("node_modules", "react-native"))) {
134
- fail("Run this from your React Native project root (react-native must be installed).");
135
- }
136
- const expo = isExpoProject();
137
- // Expo apps bundle exactly like their native build does: Expo's entry resolver (expo-router/entry,
138
- // registerRootComponent in index.ts, …) and `expo export:embed` instead of `react-native bundle`.
139
- const entry = flags.entry
140
- ? String(flags.entry)
141
- : expo
142
- ? execFileSync(process.execPath, ["-e", "require('expo/scripts/resolveAppEntry')", process.cwd(), platform, "absolute"]).toString().trim()
143
- : existsSync("index.js")
144
- ? "index.js"
145
- : "index.ts";
146
- const bundler = expo
147
- ? [createRequire(resolve("package.json")).resolve("@expo/cli", { paths: [createRequire(resolve("package.json")).resolve("expo/package.json")] }), "export:embed"]
148
- : [join("node_modules", "react-native", "cli.js"), "bundle"];
149
- const bundleName = platform === "android" ? "index.android.bundle" : "main.jsbundle";
150
- const work = mkdtempSync(join(tmpdir(), "fireota-"));
151
- const out = join(work, "package");
152
- try {
153
- const jsOut = join(work, "bundle.js");
154
- log(`Bundling ${relative(".", entry) || entry} for ${platform}${expo ? " (Expo)" : ""}…`);
155
- // Run the project's own React Native CLI through Node: no shell, same on every OS.
156
- execFileSync(
157
- process.execPath,
158
- [...bundler, "--platform", platform, "--dev", "false", "--minify", "false", "--entry-file", entry, "--bundle-output", jsOut, "--assets-dest", out],
159
- { stdio: jsonMode ? ["ignore", "ignore", "inherit"] : "inherit" },
160
- );
161
- // The bundler only creates the assets folder when the app has images.
162
- mkdirSync(out, { recursive: true });
163
- if (hermesEnabled(platform, String(flags.hermes ?? "auto"))) {
164
- log("Compiling to Hermes bytecode…");
165
- execFileSync(hermesc(), ["-O", "-w", "-emit-binary", "-out", join(out, bundleName), jsOut], {
166
- stdio: jsonMode ? ["ignore", "ignore", "inherit"] : "inherit",
167
- });
168
- } else {
169
- writeFileSync(join(out, bundleName), readFileSync(jsOut));
170
- }
171
- const packed = packDirectory(out);
172
- const target = resolve(String(flags.out && !platformOverride ? flags.out : `fireota-${platform}.fota`));
173
- writeFileSync(target, packed);
174
- log(`Package: ${target}\nSize: ${(packed.length / 1024 / 1024).toFixed(2)} MB\nSHA-256: ${sha256(packed)}`);
175
- return target;
176
- } finally {
177
- rmSync(work, { recursive: true, force: true });
178
- }
179
- }
180
-
181
- // ---------- API ----------
182
-
183
- async function api(method, path, body, extraHeaders = {}) {
184
- const base = process.env.FIREOTA_URL;
185
- const key = process.env.FIREOTA_API_KEY;
186
- if (!base) fail("Set FIREOTA_URL to your FireOTA deployment URL.");
187
- if (!key) fail("Set FIREOTA_API_KEY (Settings -> API keys).");
188
- const headers = { authorization: `Bearer ${key}`, "user-agent": "fireota-cli", ...extraHeaders };
189
- let payload = body;
190
- if (body && !(body instanceof FormData)) {
191
- headers["content-type"] = "application/json";
192
- payload = JSON.stringify(body);
193
- }
194
- let response;
195
- try {
196
- response = await fetch(`${base.replace(/\/+$/, "")}/api/public/v1${path}`, { method, headers, body: payload });
197
- } catch (error) {
198
- fail(`Could not reach ${base}: ${error instanceof Error ? error.message : error}`);
199
- }
200
- let json;
201
- try {
202
- json = await response.json();
203
- } catch {
204
- fail(`Unexpected response from FireOTA (HTTP ${response.status}).`);
205
- }
206
- if (!json.success) fail(`${json.error?.code ?? "ERROR"}: ${json.error?.message ?? "request failed"}`);
207
- return json.data;
208
- }
209
-
210
- /** --runtime / FIREOTA_RUNTIME, else the app's store version for that platform. */
211
- function runtimeFor(flags, platform) {
212
- const explicit = flags.runtime ?? process.env.FIREOTA_RUNTIME;
213
- if (explicit && explicit !== true) return String(explicit);
214
- const detected = storeVersion(platform);
215
- if (!detected) fail(`Could not read the ${platform} app version; pass --runtime <x.y.z>.`);
216
- return detected;
217
- }
218
-
219
- async function createRelease(flags, platform, extra = {}) {
220
- const ci = detectCi();
221
- const message = flags.message ?? flags.notes;
222
- const body = {
223
- appId: need(flags, "app", "FIREOTA_APP_ID"),
224
- channel: String(flags.channel ?? process.env.FIREOTA_CHANNEL ?? "production"),
225
- platform,
226
- runtimeVersion: runtimeFor(flags, platform),
227
- ...(flags.version && flags.version !== true ? { version: String(flags.version) } : {}),
228
- ...(message && message !== true ? { releaseNotes: String(message) } : {}),
229
- ...(flags.mandatory ? { mandatory: true } : {}),
230
- ...(flags.native ? { nativeVersionConstraint: String(flags.native) } : {}),
231
- ...(ci.gitCommitSha ? { gitCommitSha: ci.gitCommitSha.slice(0, 64) } : {}),
232
- ...(ci.gitBranch ? { gitBranch: ci.gitBranch.slice(0, 120) } : {}),
233
- ...(ci.buildNumber ? { buildNumber: String(ci.buildNumber).slice(0, 40) } : {}),
234
- ...extra,
235
- };
236
- // Same inputs -> same release, so a retried CI job never creates duplicates.
237
- const idempotencyKey = sha256(Buffer.from(JSON.stringify(body)));
238
- const release = await api("POST", "/releases", body, { "idempotency-key": idempotencyKey });
239
- log(`Update #${release.number} (${release.id}) — ${release.status} [${platform}, ${ci.provider}]`);
240
- return release;
241
- }
242
-
243
- async function uploadRelease(releaseId, file, signing) {
244
- if (!existsSync(file)) fail(`File not found: ${file}`);
245
- const bytes = readFileSync(file);
246
- const hash = sha256(bytes);
247
- const form = new FormData();
248
- form.append("bundle", new Blob([bytes], { type: "application/octet-stream" }), "update.fota");
249
- const headers = { "x-bundle-sha256": hash };
250
- if (signing?.key) {
251
- headers["x-fireota-signature"] = signUpdate(signing.key, { runtimeVersion: signing.runtimeVersion, platform: signing.platform, sha256: hash });
252
- log("Signed with your code signing key.");
253
- }
254
- log(`Uploading ${(bytes.length / 1024 / 1024).toFixed(2)} MB…`);
255
- const result = await api("POST", `/releases/${releaseId}/bundle`, form, headers);
256
- log(`Verified in your Firebase Storage. SHA-256 ${result.checksum}`);
257
- return result;
258
- }
259
-
260
- async function releaseAction(releaseId, body) {
261
- return api("POST", `/releases/${releaseId}/publish`, body);
262
- }
263
-
264
- /** Create → upload → publish, skipping steps a retried CI job already completed. */
265
- async function publishOne(flags, platform, file, extra, privateKey) {
266
- const release = await createRelease(flags, platform, extra);
267
- if (["draft", "failed", "uploading"].includes(release.status)) {
268
- await uploadRelease(release.id, file, privateKey ? { key: privateKey, runtimeVersion: runtimeFor(flags, platform), platform } : null);
269
- } else {
270
- log(`Bundle already uploaded (status ${release.status}); skipping upload.`);
271
- }
272
- const rollout = Number(flags.rollout ?? 100);
273
- if (release.status === "published") {
274
- log("Already published; nothing to do.");
275
- return { id: release.id, number: release.number, platform, rollout: null, status: "published" };
276
- }
277
- const result = await releaseAction(release.id, { action: "publish", rolloutPercentage: rollout });
278
- log(`Published update #${release.number} (${platform}) at ${result.rollout}%.`);
279
- return { id: release.id, number: release.number, platform, rollout: result.rollout, status: "published" };
280
- }
281
-
282
- async function main() {
283
- const [command, sub, ...rest] = process.argv.slice(2);
284
- const { positional, flags } = parseArgs([sub, ...rest].filter((v) => v !== undefined));
285
- jsonMode = Boolean(flags.json);
286
-
287
- if (!command || command === "help" || command === "--help" || flags.help) {
288
- console.log(HELP);
289
- return;
290
- }
291
- if (command === "bundle") {
292
- bundle(parseArgs(process.argv.slice(3)).flags);
293
- return;
294
- }
295
- if (command === "init") {
296
- const all = parseArgs(process.argv.slice(3)).flags;
297
- const result = await runInit(all, { api, log, fail });
298
- console.log(result.dry ? "\nFireOTA init (dry run — nothing written):\n" : "\nFireOTA is set up:\n");
299
- for (const [mark, text] of result.results) console.log(` ${mark} ${text}`);
300
- const versions = [
301
- result.runtime.android ? `Android ${result.runtime.android}` : null,
302
- result.runtime.ios ? `iOS ${result.runtime.ios}` : null,
303
- ].filter(Boolean);
304
- console.log(`
305
- Runtime version: ${versions.join(", ") || "unknown"} (your store version — bump it when native code changes).
306
-
307
- Next:
308
- 1. Rebuild the app once (release build / store build): ${result.expo ? "npx expo run:android --variant release (or EAS Build) — prebuild applies the FireOTA plugin" : `npx react-native run-android --mode release${result.runtime.ios ? "\n iOS: cd ios && pod install" : ""}`}
309
- 2. Publish an update: FIREOTA_URL=${result.serverUrl} FIREOTA_API_KEY=… npm run ota:publish -- --message "What changed"
310
- 3. Optional: show <UpdateScreen /> from '@fireota/react-native' in your settings.`);
311
- if (result.results.some(([mark]) => mark === "!")) console.log("\nSteps marked ! need a manual change — see https://github.com/Sujay-Dynamisch/FireOTA#readme");
312
- return;
313
- }
314
- if (command === "whoami") {
315
- const me = await api("GET", "/me");
316
- if (jsonMode) {
317
- console.log(JSON.stringify({ ok: true, ...me }));
318
- return;
319
- }
320
- console.log(`Workspace: ${me.organization.name} (${me.organization.slug})\nKey: ${me.key.label} — acts as ${me.key.role}\nApps:`);
321
- for (const app of me.apps) {
322
- console.log(` ${app.name}\t${app.id}\truntime ${app.runtimeVersion}${app.requiresSignedUpdates ? "\tsigned updates required" : ""}`);
323
- }
324
- return;
325
- }
326
- if (command === "signing") {
327
- if (positional[0] !== "generate") fail("usage: fireota signing generate [--out ./fireota-keys]");
328
- const keys = generateKeys(flags.out && flags.out !== true ? String(flags.out) : undefined);
329
- console.log(`Created ${keys.privatePath} (keep secret — never commit it)\nCreated ${keys.publicPath}
330
-
331
- Next:
332
- 1. Dashboard → Applications → your app → Code signing: paste ${keys.publicPath}
333
- 2. In the app: FireOTA.configure({ …, codeSigningCertificate: <contents of fireota-public.pem> })
334
- 3. Publish with --private-key ${keys.privatePath} (in CI: store the file as the FIREOTA_SIGNING_KEY secret)`);
335
- return;
336
- }
337
- if (command === "releases") {
338
- const app = flags.app ?? process.env.FIREOTA_APP_ID;
339
- const query = app ? `?appId=${encodeURIComponent(String(app))}` : "";
340
- const rows = await api("GET", `/releases${query}`);
341
- if (jsonMode) {
342
- console.log(JSON.stringify({ ok: true, releases: rows }));
343
- return;
344
- }
345
- for (const r of rows) {
346
- 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]}`);
347
- }
348
- return;
349
- }
350
- if (command === "publish") {
351
- const all = parseArgs(process.argv.slice(3)).flags;
352
- jsonMode = Boolean(all.json);
353
- const platform = need(all, "platform");
354
- if (!["android", "ios", "all"].includes(platform)) fail("--platform must be android, ios or all");
355
- if (platform === "all" && all.file) fail("--file cannot be combined with --platform all (each platform needs its own package).");
356
- let privateKey = null;
357
- try {
358
- privateKey = loadPrivateKey(all["private-key"] && all["private-key"] !== true ? String(all["private-key"]) : undefined);
359
- } catch (error) {
360
- fail(`Could not read the signing key: ${error instanceof Error ? error.message : error}`);
361
- }
362
-
363
- const platforms = platform === "all" ? ["android", "ios"] : [platform];
364
- const files = Object.fromEntries(platforms.map((p) => [p, all.file ? String(all.file) : bundle(all, p)]));
365
- // Android + iOS built from the same commit form one update group (Expo-style).
366
- const extra =
367
- platform === "all"
368
- ? { groupId: groupIdFrom(platforms.map((p) => `${sha256(readFileSync(files[p]))}@${runtimeFor(all, p)}`).join(":")) }
369
- : {};
370
- const results = [];
371
- for (const p of platforms) results.push(await publishOne(all, p, files[p], extra, privateKey));
372
- if (jsonMode) console.log(JSON.stringify({ ok: true, groupId: extra.groupId ?? null, updates: results }));
373
- return;
374
- }
375
- if (command === "release") {
376
- const id = positional[1];
377
- switch (positional[0]) {
378
- case "create": {
379
- const r = await createRelease(flags, need(flags, "platform"));
380
- if (jsonMode) console.log(JSON.stringify({ ok: true, ...r }));
381
- return;
382
- }
383
- case "upload": {
384
- let privateKey = null;
385
- try {
386
- privateKey = loadPrivateKey(flags["private-key"] && flags["private-key"] !== true ? String(flags["private-key"]) : undefined);
387
- } catch (error) {
388
- fail(`Could not read the signing key: ${error instanceof Error ? error.message : error}`);
389
- }
390
- let signing = null;
391
- if (privateKey) {
392
- const list = await api("GET", "/releases?limit=200");
393
- const target = list.find((r) => r.id === id);
394
- if (!target) fail("Release not found (needed to sign it).");
395
- signing = { key: privateKey, runtimeVersion: target.runtime_version, platform: target.platform };
396
- }
397
- const r = await uploadRelease(id ?? fail("release id required"), need(flags, "file"), signing);
398
- if (jsonMode) console.log(JSON.stringify({ ok: true, ...r }));
399
- return;
400
- }
401
- case "publish": {
402
- const r = await releaseAction(id ?? fail("release id required"), { action: "publish", rolloutPercentage: Number(flags.rollout ?? 100) });
403
- jsonMode ? console.log(JSON.stringify({ ok: true, ...r })) : console.log(`Published at ${r.rollout}%.`);
404
- return;
405
- }
406
- case "rollout": {
407
- const r = await releaseAction(id ?? fail("release id required"), { action: "rollout", rolloutPercentage: Number(need(flags, "percentage")) });
408
- jsonMode ? console.log(JSON.stringify({ ok: true, ...r })) : console.log(`Rollout set to ${r.rollout}%.`);
409
- return;
410
- }
411
- case "pause": {
412
- const r = await releaseAction(id ?? fail("release id required"), { action: "pause" });
413
- jsonMode ? console.log(JSON.stringify({ ok: true, ...r })) : console.log("Paused.");
414
- return;
415
- }
416
- case "rollback": {
417
- const r = await releaseAction(id ?? fail("release id required"), {
418
- action: "rollback",
419
- reason: need(flags, "reason"),
420
- ...(flags.target ? { targetReleaseId: String(flags.target) } : {}),
421
- });
422
- if (jsonMode) console.log(JSON.stringify({ ok: true, ...r }));
423
- else console.log(r.restoredReleaseId ? `Rolled back; now serving ${r.restoredReleaseId}.` : "Rolled back; channel has no active release.");
424
- return;
425
- }
426
- default:
427
- fail(`unknown release command: ${positional[0] ?? ""}\n\n${HELP}`);
428
- }
429
- }
430
- fail(`unknown command: ${command}\n\n${HELP}`);
431
- }
432
-
433
- main().catch((error) => fail(error instanceof Error ? error.message : String(error)));
1
+ #!/usr/bin/env node
2
+ /**
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
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import { createHash } from "node:crypto";
12
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { createRequire } from "node:module";
15
+ import { join, relative, resolve } from "node:path";
16
+
17
+ import { detectCi } from "../lib/ci.mjs";
18
+ import { runInit } from "../lib/init.mjs";
19
+ import { packDirectory } from "../lib/package.mjs";
20
+ import { isExpoProject, readExpoAppJson, storeVersion } from "../lib/project.mjs";
21
+ import { generateKeys, loadPrivateKey, signUpdate } from "../lib/signing.mjs";
22
+
23
+ const HELP = `FireOTA CLI
24
+
25
+ Usage:
26
+ fireota init [--app <appId>] [--server-url <url>] [--channel production] [--dry-run] [--force]
27
+ One-time setup: creates fireota.config.js, wraps your root component with the
28
+ built-in update UI, and wires MainApplication / AppDelegate for release builds.
29
+ fireota publish --app <appId> --platform <android|ios|all> [--runtime <x.y.z>] --message "What changed"
30
+ [--channel production] [--rollout 10] [--version <label>] [--mandatory] [--native ">=1.4.0"]
31
+ [--file update.fota] [--private-key fireota-private.pem] [--expo] [--json]
32
+ Builds (unless --file), uploads to your Firebase Storage and publishes.
33
+ --platform all publishes Android + iOS as one update group.
34
+ --expo builds with \`expo export\` for apps that use expo-updates (no EAS Update):
35
+ point app.json updates.url at <FIREOTA_URL>/api/public/v1/expo/<appId>/manifest.
36
+ fireota bundle --platform <android|ios> [--entry index.js] [--out update.fota] [--hermes auto|on|off]
37
+ fireota whoami Shows the workspace, key role and apps behind FIREOTA_API_KEY.
38
+ fireota signing generate [--out ./fireota-keys] Creates an RSA key pair for code signing.
39
+ fireota release create --app <appId> --platform <p> --runtime <r> [--message m] [--version v] [--channel c]
40
+ fireota release upload <releaseId> --file update.fota [--private-key key.pem]
41
+ fireota release publish <releaseId> [--rollout 100]
42
+ fireota release rollout <releaseId> --percentage <0-100>
43
+ fireota release pause <releaseId>
44
+ fireota release rollback <releaseId> --reason "..." [--target <releaseId>]
45
+ fireota releases [--app <appId>] [--json]
46
+
47
+ Updates are identified by their ID (like Expo): --version is only an optional label, and
48
+ --runtime must equal the runtime version of the store build the update is for. Without
49
+ --runtime, the app's store version is used (android/app/build.gradle versionName, iOS
50
+ MARKETING_VERSION) — the same default the SDK uses.
51
+
52
+ Environment: FIREOTA_URL, FIREOTA_API_KEY (required); FIREOTA_APP_ID, FIREOTA_CHANNEL,
53
+ FIREOTA_RUNTIME, FIREOTA_SIGNING_KEY / FIREOTA_SIGNING_KEY_PATH (optional).
54
+ Works on GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps, CircleCI, Bitrise and any
55
+ other CI: commit, branch and build number are detected automatically.
56
+ Exit codes: 0 success, 1 error.`;
57
+
58
+ let jsonMode = false;
59
+ /** Human progress goes to stderr in --json mode so stdout stays machine-readable. */
60
+ const log = (...args) => (jsonMode ? console.error(...args) : console.log(...args));
61
+
62
+ function parseArgs(argv) {
63
+ const positional = [];
64
+ const flags = {};
65
+ for (let i = 0; i < argv.length; i += 1) {
66
+ const arg = argv[i];
67
+ if (arg.startsWith("--")) {
68
+ const key = arg.slice(2);
69
+ const next = argv[i + 1];
70
+ if (next === undefined || next.startsWith("--")) flags[key] = true;
71
+ else {
72
+ flags[key] = next;
73
+ i += 1;
74
+ }
75
+ } else positional.push(arg);
76
+ }
77
+ return { positional, flags };
78
+ }
79
+
80
+ function fail(message) {
81
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: message }));
82
+ console.error(`fireota: ${message}`);
83
+ process.exit(1);
84
+ }
85
+
86
+ function need(flags, key, envName) {
87
+ const value = flags[key] ?? (envName ? process.env[envName] : undefined);
88
+ if (value === undefined || value === true || value === "") fail(`--${key} is required${envName ? ` (or set ${envName})` : ""}`);
89
+ return String(value);
90
+ }
91
+
92
+ function sha256(buffer) {
93
+ return createHash("sha256").update(buffer).digest("hex");
94
+ }
95
+
96
+ /** Deterministic RFC-4122-shaped id, so a retried CI job reuses the same update group. */
97
+ function groupIdFrom(seed) {
98
+ const h = sha256(Buffer.from(seed));
99
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
100
+ }
101
+
102
+ // ---------- packaging ----------
103
+
104
+ function hermesEnabled(platform, mode) {
105
+ if (mode === "on") return true;
106
+ if (mode === "off") return false;
107
+ // Expo without android/ or ios/ yet: the engine is set in app.json (Hermes unless "jsEngine": "jsc").
108
+ if (isExpoProject() && !existsSync(platform === "android" ? "android" : "ios")) {
109
+ const expo = readExpoAppJson() ?? {};
110
+ return (expo[platform]?.jsEngine ?? expo.jsEngine ?? "hermes") !== "jsc";
111
+ }
112
+ if (platform === "android") {
113
+ const props = existsSync("android/gradle.properties") ? readFileSync("android/gradle.properties", "utf8") : "";
114
+ return !/^\s*hermesEnabled\s*=\s*false/m.test(props);
115
+ }
116
+ const podfile = existsSync("ios/Podfile") ? readFileSync("ios/Podfile", "utf8") : "";
117
+ return !/:hermes_enabled\s*=>\s*false/.test(podfile);
118
+ }
119
+
120
+ function hermesc() {
121
+ const dir = { win32: "win64-bin", darwin: "osx-bin", linux: "linux64-bin" }[process.platform];
122
+ const candidates = [
123
+ join("node_modules", "react-native", "sdks", "hermesc", dir ?? "", process.platform === "win32" ? "hermesc.exe" : "hermesc"),
124
+ join("node_modules", "hermes-compiler", "hermesc", dir ?? "", process.platform === "win32" ? "hermesc.exe" : "hermesc"),
125
+ ];
126
+ const found = candidates.find((c) => existsSync(c));
127
+ if (!found) fail("Hermes is enabled but hermesc was not found in node_modules. Run npm install, or pass --hermes off.");
128
+ return found;
129
+ }
130
+
131
+ /** Runs the React Native bundler (+ Hermes) and packs bundle and assets into one update file. */
132
+ function bundle(flags, platformOverride) {
133
+ const platform = platformOverride ?? need(flags, "platform");
134
+ if (!["android", "ios"].includes(platform)) fail("--platform must be android or ios");
135
+ if (!existsSync("package.json") || !existsSync(join("node_modules", "react-native"))) {
136
+ fail("Run this from your React Native project root (react-native must be installed).");
137
+ }
138
+ const expo = isExpoProject();
139
+ // Expo apps bundle exactly like their native build does: Expo's entry resolver (expo-router/entry,
140
+ // registerRootComponent in index.ts, …) and `expo export:embed` instead of `react-native bundle`.
141
+ const entry = flags.entry
142
+ ? String(flags.entry)
143
+ : expo
144
+ ? execFileSync(process.execPath, ["-e", "require('expo/scripts/resolveAppEntry')", process.cwd(), platform, "absolute"]).toString().trim()
145
+ : existsSync("index.js")
146
+ ? "index.js"
147
+ : "index.ts";
148
+ const bundler = expo
149
+ ? [createRequire(resolve("package.json")).resolve("@expo/cli", { paths: [createRequire(resolve("package.json")).resolve("expo/package.json")] }), "export:embed"]
150
+ : [join("node_modules", "react-native", "cli.js"), "bundle"];
151
+ const bundleName = platform === "android" ? "index.android.bundle" : "main.jsbundle";
152
+ const work = mkdtempSync(join(tmpdir(), "fireota-"));
153
+ const out = join(work, "package");
154
+ try {
155
+ const jsOut = join(work, "bundle.js");
156
+ log(`Bundling ${relative(".", entry) || entry} for ${platform}${expo ? " (Expo)" : ""}…`);
157
+ // Run the project's own React Native CLI through Node: no shell, same on every OS.
158
+ execFileSync(
159
+ process.execPath,
160
+ [...bundler, "--platform", platform, "--dev", "false", "--minify", "false", "--entry-file", entry, "--bundle-output", jsOut, "--assets-dest", out],
161
+ { stdio: jsonMode ? ["ignore", "ignore", "inherit"] : "inherit" },
162
+ );
163
+ // The bundler only creates the assets folder when the app has images.
164
+ mkdirSync(out, { recursive: true });
165
+ if (hermesEnabled(platform, String(flags.hermes ?? "auto"))) {
166
+ log("Compiling to Hermes bytecode…");
167
+ execFileSync(hermesc(), ["-O", "-w", "-emit-binary", "-out", join(out, bundleName), jsOut], {
168
+ stdio: jsonMode ? ["ignore", "ignore", "inherit"] : "inherit",
169
+ });
170
+ } else {
171
+ writeFileSync(join(out, bundleName), readFileSync(jsOut));
172
+ }
173
+ const packed = packDirectory(out);
174
+ const target = resolve(String(flags.out && !platformOverride ? flags.out : `fireota-${platform}.fota`));
175
+ writeFileSync(target, packed);
176
+ log(`Package: ${target}\nSize: ${(packed.length / 1024 / 1024).toFixed(2)} MB\nSHA-256: ${sha256(packed)}`);
177
+ return target;
178
+ } finally {
179
+ rmSync(work, { recursive: true, force: true });
180
+ }
181
+ }
182
+
183
+ // ---------- API ----------
184
+
185
+ async function api(method, path, body, extraHeaders = {}) {
186
+ const base = process.env.FIREOTA_URL;
187
+ const key = process.env.FIREOTA_API_KEY;
188
+ if (!base) fail("Set FIREOTA_URL to your FireOTA deployment URL.");
189
+ if (!key) fail("Set FIREOTA_API_KEY (Settings -> API keys).");
190
+ const headers = { authorization: `Bearer ${key}`, "user-agent": "fireota-cli", ...extraHeaders };
191
+ let payload = body;
192
+ if (body && !(body instanceof FormData)) {
193
+ headers["content-type"] = "application/json";
194
+ payload = JSON.stringify(body);
195
+ }
196
+ let response;
197
+ try {
198
+ response = await fetch(`${base.replace(/\/+$/, "")}/api/public/v1${path}`, { method, headers, body: payload });
199
+ } catch (error) {
200
+ fail(`Could not reach ${base}: ${error instanceof Error ? error.message : error}`);
201
+ }
202
+ let json;
203
+ try {
204
+ json = await response.json();
205
+ } catch {
206
+ fail(`Unexpected response from FireOTA (HTTP ${response.status}).`);
207
+ }
208
+ if (!json.success) fail(`${json.error?.code ?? "ERROR"}: ${json.error?.message ?? "request failed"}`);
209
+ return json.data;
210
+ }
211
+
212
+ /** --runtime / FIREOTA_RUNTIME, else the app's store version for that platform. */
213
+ function runtimeFor(flags, platform) {
214
+ const explicit = flags.runtime ?? process.env.FIREOTA_RUNTIME;
215
+ if (explicit && explicit !== true) return String(explicit);
216
+ const detected = storeVersion(platform);
217
+ if (!detected) fail(`Could not read the ${platform} app version; pass --runtime <x.y.z>.`);
218
+ return detected;
219
+ }
220
+
221
+ async function createRelease(flags, platform, extra = {}) {
222
+ const ci = detectCi();
223
+ const message = flags.message ?? flags.notes;
224
+ const body = {
225
+ appId: need(flags, "app", "FIREOTA_APP_ID"),
226
+ channel: String(flags.channel ?? process.env.FIREOTA_CHANNEL ?? "production"),
227
+ platform,
228
+ runtimeVersion: runtimeFor(flags, platform),
229
+ ...(flags.version && flags.version !== true ? { version: String(flags.version) } : {}),
230
+ ...(message && message !== true ? { releaseNotes: String(message) } : {}),
231
+ ...(flags.mandatory ? { mandatory: true } : {}),
232
+ ...(flags.native ? { nativeVersionConstraint: String(flags.native) } : {}),
233
+ ...(ci.gitCommitSha ? { gitCommitSha: ci.gitCommitSha.slice(0, 64) } : {}),
234
+ ...(ci.gitBranch ? { gitBranch: ci.gitBranch.slice(0, 120) } : {}),
235
+ ...(ci.buildNumber ? { buildNumber: String(ci.buildNumber).slice(0, 40) } : {}),
236
+ ...extra,
237
+ };
238
+ // Same inputs -> same release, so a retried CI job never creates duplicates.
239
+ const idempotencyKey = sha256(Buffer.from(JSON.stringify(body)));
240
+ const release = await api("POST", "/releases", body, { "idempotency-key": idempotencyKey });
241
+ log(`Update #${release.number} (${release.id}) — ${release.status} [${platform}, ${ci.provider}]`);
242
+ return release;
243
+ }
244
+
245
+ async function uploadRelease(releaseId, file, signing) {
246
+ if (!existsSync(file)) fail(`File not found: ${file}`);
247
+ const bytes = readFileSync(file);
248
+ const hash = sha256(bytes);
249
+ const form = new FormData();
250
+ form.append("bundle", new Blob([bytes], { type: "application/octet-stream" }), "update.fota");
251
+ const headers = { "x-bundle-sha256": hash };
252
+ if (signing?.key) {
253
+ headers["x-fireota-signature"] = signUpdate(signing.key, { runtimeVersion: signing.runtimeVersion, platform: signing.platform, sha256: hash });
254
+ log("Signed with your code signing key.");
255
+ }
256
+ log(`Uploading ${(bytes.length / 1024 / 1024).toFixed(2)} MB…`);
257
+ const result = await api("POST", `/releases/${releaseId}/bundle`, form, headers);
258
+ log(`Verified in your Firebase Storage. SHA-256 ${result.checksum}`);
259
+ return result;
260
+ }
261
+
262
+ // ---------- Expo (expo-updates clients) ----------
263
+
264
+ function expoCli() {
265
+ if (!isExpoProject()) fail("--expo needs an Expo project (the `expo` package must be installed).");
266
+ const req = createRequire(resolve("package.json"));
267
+ return req.resolve("@expo/cli", { paths: [req.resolve("expo/package.json")] });
268
+ }
269
+
270
+ /** The public app config, as expo-updates exposes it to the app (Constants.expoConfig). */
271
+ function expoPublicConfig() {
272
+ const out = execFileSync(process.execPath, [expoCli(), "config", "--json", "--type", "public"], {
273
+ stdio: ["ignore", "pipe", "inherit"],
274
+ maxBuffer: 16 * 1024 * 1024,
275
+ });
276
+ return JSON.parse(out.toString());
277
+ }
278
+
279
+ /** Runtime version for expo-updates: --runtime, else app config `runtimeVersion` (string or appVersion policy). */
280
+ function expoRuntime(flags, platform, config) {
281
+ const explicit = flags.runtime ?? process.env.FIREOTA_RUNTIME;
282
+ if (explicit && explicit !== true) return String(explicit);
283
+ const value = config[platform]?.runtimeVersion ?? config.runtimeVersion;
284
+ if (typeof value === "string" && value) return value;
285
+ if (value?.policy === "appVersion" && config.version) return String(config.version);
286
+ fail(`Could not resolve the ${platform} runtime version from app.json. Set "runtimeVersion": "1.0.0" (or the "appVersion" policy), or pass --runtime <x.y.z>.`);
287
+ }
288
+
289
+ /** `expo export` for one platform: the launch bundle plus each asset, as expo-updates expects. */
290
+ function expoExport(platform) {
291
+ const dir = mkdtempSync(join(tmpdir(), "fireota-expo-"));
292
+ log(`Exporting ${platform} update with expo export…`);
293
+ execFileSync(process.execPath, [expoCli(), "export", "--platform", platform, "--output-dir", dir], {
294
+ stdio: jsonMode ? ["ignore", "ignore", "inherit"] : "inherit",
295
+ });
296
+ const metadata = JSON.parse(readFileSync(join(dir, "metadata.json"), "utf8"));
297
+ const files = metadata.fileMetadata?.[platform];
298
+ if (!files?.bundle) fail(`expo export produced no ${platform} bundle.`);
299
+ return {
300
+ dir,
301
+ bundle: join(dir, files.bundle),
302
+ assets: (files.assets ?? []).map((a) => ({ path: join(dir, a.path), name: a.path.split("/").pop(), ext: a.ext ?? "" })),
303
+ };
304
+ }
305
+
306
+ async function uploadExpoRelease(releaseId, exported, config) {
307
+ const bytes = readFileSync(exported.bundle);
308
+ const form = new FormData();
309
+ form.append("format", "expo");
310
+ form.append("bundle", new Blob([bytes], { type: "application/javascript" }), "bundle.hbc");
311
+ for (const asset of exported.assets) {
312
+ form.append("assets", new Blob([readFileSync(asset.path)]), asset.name);
313
+ }
314
+ form.append("expoAssets", JSON.stringify(exported.assets.map((a) => ({ name: a.name, ext: a.ext }))));
315
+ form.append("expoConfig", JSON.stringify(config));
316
+ log(`Uploading bundle + ${exported.assets.length} assets…`);
317
+ const result = await api("POST", `/releases/${releaseId}/bundle`, form, { "x-bundle-sha256": sha256(bytes) });
318
+ log(`Verified in your Firebase Storage. SHA-256 ${result.checksum}`);
319
+ return result;
320
+ }
321
+
322
+ async function publishExpo(flags, platforms) {
323
+ if (flags.file) fail("--file cannot be combined with --expo; the update is built with expo export.");
324
+ if (flags["private-key"]) fail("Code signing is not supported for Expo updates yet; publish without --private-key.");
325
+ const config = expoPublicConfig();
326
+ const exports = Object.fromEntries(platforms.map((p) => [p, expoExport(p)]));
327
+ try {
328
+ const runtimes = Object.fromEntries(platforms.map((p) => [p, expoRuntime(flags, p, config)]));
329
+ const extra =
330
+ platforms.length > 1
331
+ ? { groupId: groupIdFrom(platforms.map((p) => `${sha256(readFileSync(exports[p].bundle))}@${runtimes[p]}`).join(":")) }
332
+ : {};
333
+ const results = [];
334
+ for (const p of platforms) {
335
+ const release = await createRelease({ ...flags, runtime: runtimes[p] }, p, extra);
336
+ if (["draft", "failed", "uploading"].includes(release.status)) await uploadExpoRelease(release.id, exports[p], config);
337
+ else log(`Bundle already uploaded (status ${release.status}); skipping upload.`);
338
+ if (release.status === "published") {
339
+ results.push({ id: release.id, number: release.number, platform: p, rollout: null, status: "published" });
340
+ continue;
341
+ }
342
+ const result = await releaseAction(release.id, { action: "publish", rolloutPercentage: Number(flags.rollout ?? 100) });
343
+ log(`Published update #${release.number} (${p}) at ${result.rollout}%.`);
344
+ results.push({ id: release.id, number: release.number, platform: p, rollout: result.rollout, status: "published" });
345
+ }
346
+ if (jsonMode) console.log(JSON.stringify({ ok: true, groupId: extra.groupId ?? null, updates: results }));
347
+ else log(`\nexpo-updates URL for app.json:\n ${process.env.FIREOTA_URL?.replace(/\/+$/, "")}/api/public/v1/expo/${need(flags, "app", "FIREOTA_APP_ID")}/manifest`);
348
+ } finally {
349
+ for (const e of Object.values(exports)) rmSync(e.dir, { recursive: true, force: true });
350
+ }
351
+ }
352
+
353
+ async function releaseAction(releaseId, body) {
354
+ return api("POST", `/releases/${releaseId}/publish`, body);
355
+ }
356
+
357
+ /** Create → upload → publish, skipping steps a retried CI job already completed. */
358
+ async function publishOne(flags, platform, file, extra, privateKey) {
359
+ const release = await createRelease(flags, platform, extra);
360
+ if (["draft", "failed", "uploading"].includes(release.status)) {
361
+ await uploadRelease(release.id, file, privateKey ? { key: privateKey, runtimeVersion: runtimeFor(flags, platform), platform } : null);
362
+ } else {
363
+ log(`Bundle already uploaded (status ${release.status}); skipping upload.`);
364
+ }
365
+ const rollout = Number(flags.rollout ?? 100);
366
+ if (release.status === "published") {
367
+ log("Already published; nothing to do.");
368
+ return { id: release.id, number: release.number, platform, rollout: null, status: "published" };
369
+ }
370
+ const result = await releaseAction(release.id, { action: "publish", rolloutPercentage: rollout });
371
+ log(`Published update #${release.number} (${platform}) at ${result.rollout}%.`);
372
+ return { id: release.id, number: release.number, platform, rollout: result.rollout, status: "published" };
373
+ }
374
+
375
+ async function main() {
376
+ const [command, sub, ...rest] = process.argv.slice(2);
377
+ const { positional, flags } = parseArgs([sub, ...rest].filter((v) => v !== undefined));
378
+ jsonMode = Boolean(flags.json);
379
+
380
+ if (!command || command === "help" || command === "--help" || flags.help) {
381
+ console.log(HELP);
382
+ return;
383
+ }
384
+ if (command === "bundle") {
385
+ bundle(parseArgs(process.argv.slice(3)).flags);
386
+ return;
387
+ }
388
+ if (command === "init") {
389
+ const all = parseArgs(process.argv.slice(3)).flags;
390
+ const result = await runInit(all, { api, log, fail });
391
+ console.log(result.dry ? "\nFireOTA init (dry run — nothing written):\n" : "\nFireOTA is set up:\n");
392
+ for (const [mark, text] of result.results) console.log(` ${mark} ${text}`);
393
+ const versions = [
394
+ result.runtime.android ? `Android ${result.runtime.android}` : null,
395
+ result.runtime.ios ? `iOS ${result.runtime.ios}` : null,
396
+ ].filter(Boolean);
397
+ console.log(`
398
+ Runtime version: ${versions.join(", ") || "unknown"} (your store version — bump it when native code changes).
399
+
400
+ Next:
401
+ 1. Rebuild the app once (release build / store build): ${result.expo ? "npx expo run:android --variant release (or EAS Build) — prebuild applies the FireOTA plugin" : `npx react-native run-android --mode release${result.runtime.ios ? "\n iOS: cd ios && pod install" : ""}`}
402
+ 2. Publish an update: FIREOTA_URL=${result.serverUrl} FIREOTA_API_KEY=… npm run ota:publish -- --message "What changed"
403
+ 3. Optional: show <UpdateScreen /> from '@fireota/react-native' in your settings.`);
404
+ if (result.results.some(([mark]) => mark === "!")) console.log("\nSteps marked ! need a manual change — see https://github.com/Sujay-Dynamisch/FireOTA#readme");
405
+ return;
406
+ }
407
+ if (command === "whoami") {
408
+ const me = await api("GET", "/me");
409
+ if (jsonMode) {
410
+ console.log(JSON.stringify({ ok: true, ...me }));
411
+ return;
412
+ }
413
+ console.log(`Workspace: ${me.organization.name} (${me.organization.slug})\nKey: ${me.key.label} — acts as ${me.key.role}\nApps:`);
414
+ for (const app of me.apps) {
415
+ console.log(` ${app.name}\t${app.id}\truntime ${app.runtimeVersion}${app.requiresSignedUpdates ? "\tsigned updates required" : ""}`);
416
+ }
417
+ return;
418
+ }
419
+ if (command === "signing") {
420
+ if (positional[0] !== "generate") fail("usage: fireota signing generate [--out ./fireota-keys]");
421
+ const keys = generateKeys(flags.out && flags.out !== true ? String(flags.out) : undefined);
422
+ console.log(`Created ${keys.privatePath} (keep secret — never commit it)\nCreated ${keys.publicPath}
423
+
424
+ Next:
425
+ 1. Dashboard → Applications → your app → Code signing: paste ${keys.publicPath}
426
+ 2. In the app: FireOTA.configure({ …, codeSigningCertificate: <contents of fireota-public.pem> })
427
+ 3. Publish with --private-key ${keys.privatePath} (in CI: store the file as the FIREOTA_SIGNING_KEY secret)`);
428
+ return;
429
+ }
430
+ if (command === "releases") {
431
+ const app = flags.app ?? process.env.FIREOTA_APP_ID;
432
+ const query = app ? `?appId=${encodeURIComponent(String(app))}` : "";
433
+ const rows = await api("GET", `/releases${query}`);
434
+ if (jsonMode) {
435
+ console.log(JSON.stringify({ ok: true, releases: rows }));
436
+ return;
437
+ }
438
+ for (const r of rows) {
439
+ 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]}`);
440
+ }
441
+ return;
442
+ }
443
+ if (command === "publish") {
444
+ const all = parseArgs(process.argv.slice(3)).flags;
445
+ jsonMode = Boolean(all.json);
446
+ const platform = need(all, "platform");
447
+ if (!["android", "ios", "all"].includes(platform)) fail("--platform must be android, ios or all");
448
+ if (all.expo) return publishExpo(all, platform === "all" ? ["android", "ios"] : [platform]);
449
+ if (platform === "all" && all.file) fail("--file cannot be combined with --platform all (each platform needs its own package).");
450
+ let privateKey = null;
451
+ try {
452
+ privateKey = loadPrivateKey(all["private-key"] && all["private-key"] !== true ? String(all["private-key"]) : undefined);
453
+ } catch (error) {
454
+ fail(`Could not read the signing key: ${error instanceof Error ? error.message : error}`);
455
+ }
456
+
457
+ const platforms = platform === "all" ? ["android", "ios"] : [platform];
458
+ const files = Object.fromEntries(platforms.map((p) => [p, all.file ? String(all.file) : bundle(all, p)]));
459
+ // Android + iOS built from the same commit form one update group (Expo-style).
460
+ const extra =
461
+ platform === "all"
462
+ ? { groupId: groupIdFrom(platforms.map((p) => `${sha256(readFileSync(files[p]))}@${runtimeFor(all, p)}`).join(":")) }
463
+ : {};
464
+ const results = [];
465
+ for (const p of platforms) results.push(await publishOne(all, p, files[p], extra, privateKey));
466
+ if (jsonMode) console.log(JSON.stringify({ ok: true, groupId: extra.groupId ?? null, updates: results }));
467
+ return;
468
+ }
469
+ if (command === "release") {
470
+ const id = positional[1];
471
+ switch (positional[0]) {
472
+ case "create": {
473
+ const r = await createRelease(flags, need(flags, "platform"));
474
+ if (jsonMode) console.log(JSON.stringify({ ok: true, ...r }));
475
+ return;
476
+ }
477
+ case "upload": {
478
+ let privateKey = null;
479
+ try {
480
+ privateKey = loadPrivateKey(flags["private-key"] && flags["private-key"] !== true ? String(flags["private-key"]) : undefined);
481
+ } catch (error) {
482
+ fail(`Could not read the signing key: ${error instanceof Error ? error.message : error}`);
483
+ }
484
+ let signing = null;
485
+ if (privateKey) {
486
+ const list = await api("GET", "/releases?limit=200");
487
+ const target = list.find((r) => r.id === id);
488
+ if (!target) fail("Release not found (needed to sign it).");
489
+ signing = { key: privateKey, runtimeVersion: target.runtime_version, platform: target.platform };
490
+ }
491
+ const r = await uploadRelease(id ?? fail("release id required"), need(flags, "file"), signing);
492
+ if (jsonMode) console.log(JSON.stringify({ ok: true, ...r }));
493
+ return;
494
+ }
495
+ case "publish": {
496
+ const r = await releaseAction(id ?? fail("release id required"), { action: "publish", rolloutPercentage: Number(flags.rollout ?? 100) });
497
+ jsonMode ? console.log(JSON.stringify({ ok: true, ...r })) : console.log(`Published at ${r.rollout}%.`);
498
+ return;
499
+ }
500
+ case "rollout": {
501
+ const r = await releaseAction(id ?? fail("release id required"), { action: "rollout", rolloutPercentage: Number(need(flags, "percentage")) });
502
+ jsonMode ? console.log(JSON.stringify({ ok: true, ...r })) : console.log(`Rollout set to ${r.rollout}%.`);
503
+ return;
504
+ }
505
+ case "pause": {
506
+ const r = await releaseAction(id ?? fail("release id required"), { action: "pause" });
507
+ jsonMode ? console.log(JSON.stringify({ ok: true, ...r })) : console.log("Paused.");
508
+ return;
509
+ }
510
+ case "rollback": {
511
+ const r = await releaseAction(id ?? fail("release id required"), {
512
+ action: "rollback",
513
+ reason: need(flags, "reason"),
514
+ ...(flags.target ? { targetReleaseId: String(flags.target) } : {}),
515
+ });
516
+ if (jsonMode) console.log(JSON.stringify({ ok: true, ...r }));
517
+ else console.log(r.restoredReleaseId ? `Rolled back; now serving ${r.restoredReleaseId}.` : "Rolled back; channel has no active release.");
518
+ return;
519
+ }
520
+ default:
521
+ fail(`unknown release command: ${positional[0] ?? ""}\n\n${HELP}`);
522
+ }
523
+ }
524
+ fail(`unknown command: ${command}\n\n${HELP}`);
525
+ }
526
+
527
+ main().catch((error) => fail(error instanceof Error ? error.message : String(error)));