@fireota/cli 0.5.0 → 0.7.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
@@ -70,7 +70,7 @@ fireota publish --app <appId> --platform <android|ios|all> [--runtime <x.y.z>]
70
70
  fireota bundle --platform <android|ios> [--entry index.js] [--out update.fota] [--hermes auto|on|off]
71
71
  fireota whoami
72
72
  fireota signing generate [--out ./fireota-keys]
73
- fireota release create --app <appId> --platform <p> --runtime <r> [--message m] [--version v] [--channel c]
73
+ fireota release create --app <appId> --platform <p> --runtime <r> [--bundle-url <url>] [--bundle-hash <sha256>] [--bundle-size <bytes>] [--message m] [--version v] [--channel c]
74
74
  fireota release upload <releaseId> --file update.fota [--private-key key.pem]
75
75
  fireota release publish <releaseId> [--rollout 100]
76
76
  fireota release rollout <releaseId> --percentage <0-100>
@@ -112,6 +112,10 @@ script:
112
112
  - npx fireota publish --app "$FIREOTA_APP_ID" --platform android --runtime 1.3.0 --message "$(git log -1 --pretty=%s)"
113
113
  ```
114
114
 
115
+ ## Changelog
116
+
117
+ See the root [CHANGELOG.md](../../CHANGELOG.md) for version release notes and update history.
118
+
115
119
  ## License
116
120
 
117
121
  MIT
package/bin/fireota.mjs CHANGED
@@ -9,15 +9,33 @@
9
9
  */
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { createHash } from "node:crypto";
12
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
14
  import { createRequire } from "node:module";
15
15
  import { join, relative, resolve } from "node:path";
16
16
 
17
17
  import { detectCi } from "../lib/ci.mjs";
18
+ import {
19
+ buildPatchApk,
20
+ buildStore,
21
+ flutter as runFlutter,
22
+ flutterConfigSource,
23
+ flutterVersionInfo,
24
+ isFlutterProject,
25
+ libappByAbi,
26
+ newBuildId,
27
+ patchCompatibilityProblems,
28
+ patchFlutterActivity,
29
+ patchFlutterMain,
30
+ pluginsHash,
31
+ readBaseRecord,
32
+ readPubspec,
33
+ sha256 as sha256Hex,
34
+ writeBaseRecord,
35
+ } from "../lib/flutter.mjs";
18
36
  import { runInit } from "../lib/init.mjs";
19
37
  import { packDirectory } from "../lib/package.mjs";
20
- import { isExpoProject, readExpoAppJson, storeVersion } from "../lib/project.mjs";
38
+ import { findFile, isExpoProject, readExpoAppJson, storeVersion } from "../lib/project.mjs";
21
39
  import { generateKeys, loadPrivateKey, signUpdate } from "../lib/signing.mjs";
22
40
 
23
41
  const HELP = `FireOTA CLI
@@ -44,6 +62,21 @@ Usage:
44
62
  fireota release rollback <releaseId> --reason "..." [--target <releaseId>]
45
63
  fireota releases [--app <appId>] [--json]
46
64
 
65
+ Flutter (pub.dev package \`fireota\`):
66
+ fireota init In a Flutter project: writes lib/fireota_config.dart, starts FireOTA in main.dart
67
+ and makes MainActivity extend FireOTAFlutterActivity.
68
+ fireota flutter release android [--apk] [-- <flutter build args>]
69
+ Builds the store build (app bundle, or --apk) and records its fingerprints in
70
+ .fireota/flutter/ — commit that file; patches are checked against it.
71
+ fireota flutter patch android --app <appId> --message "What changed" --private-key key.pem
72
+ [--abis arm64-v8a,x86_64] [--assets <dir>] [--config config.json] [--channel c]
73
+ [--rollout 10] [--mandatory] [--out patch.fota --dry-run] [--force] [-- <flutter build args>]
74
+ Compiles your current Dart code into a signed code patch for the recorded store
75
+ build and publishes it. Android only; code patches must be signed.
76
+ fireota flutter assets --platform <android|ios|all> --app <appId> --assets <dir> [--config config.json]
77
+ [--message m] [--private-key key.pem] [--channel c] [--rollout 10]
78
+ Publishes asset and remote-config updates (Android and iOS).
79
+
47
80
  Updates are identified by their ID (like Expo): --version is only an optional label, and
48
81
  --runtime must equal the runtime version of the store build the update is for. Without
49
82
  --runtime, the app's store version is used (android/app/build.gradle versionName, iOS
@@ -233,6 +266,15 @@ async function createRelease(flags, platform, extra = {}) {
233
266
  ...(ci.gitCommitSha ? { gitCommitSha: ci.gitCommitSha.slice(0, 64) } : {}),
234
267
  ...(ci.gitBranch ? { gitBranch: ci.gitBranch.slice(0, 120) } : {}),
235
268
  ...(ci.buildNumber ? { buildNumber: String(ci.buildNumber).slice(0, 40) } : {}),
269
+ ...(flags["bundle-url"]
270
+ ? {
271
+ externalBundle: {
272
+ url: String(flags["bundle-url"]),
273
+ sha256: String(flags["bundle-hash"] || flags.hash || ""),
274
+ size: Number(flags["bundle-size"] || flags.size || 0),
275
+ },
276
+ }
277
+ : {}),
236
278
  ...extra,
237
279
  };
238
280
  // Same inputs -> same release, so a retried CI job never creates duplicates.
@@ -255,7 +297,7 @@ async function uploadRelease(releaseId, file, signing) {
255
297
  }
256
298
  log(`Uploading ${(bytes.length / 1024 / 1024).toFixed(2)} MB…`);
257
299
  const result = await api("POST", `/releases/${releaseId}/bundle`, form, headers);
258
- log(`Verified in your Firebase Storage. SHA-256 ${result.checksum}`);
300
+ log(`Verified in your cloud storage. SHA-256 ${result.checksum}`);
259
301
  return result;
260
302
  }
261
303
 
@@ -315,7 +357,7 @@ async function uploadExpoRelease(releaseId, exported, config) {
315
357
  form.append("expoConfig", JSON.stringify(config));
316
358
  log(`Uploading bundle + ${exported.assets.length} assets…`);
317
359
  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}`);
360
+ log(`Verified in your cloud storage. SHA-256 ${result.checksum}`);
319
361
  return result;
320
362
  }
321
363
 
@@ -357,7 +399,9 @@ async function releaseAction(releaseId, body) {
357
399
  /** Create → upload → publish, skipping steps a retried CI job already completed. */
358
400
  async function publishOne(flags, platform, file, extra, privateKey) {
359
401
  const release = await createRelease(flags, platform, extra);
360
- if (["draft", "failed", "uploading"].includes(release.status)) {
402
+ if (flags["bundle-url"]) {
403
+ log(`Using external bundle URL: ${flags["bundle-url"]}`);
404
+ } else if (["draft", "failed", "uploading"].includes(release.status)) {
361
405
  await uploadRelease(release.id, file, privateKey ? { key: privateKey, runtimeVersion: runtimeFor(flags, platform), platform } : null);
362
406
  } else {
363
407
  log(`Bundle already uploaded (status ${release.status}); skipping upload.`);
@@ -372,6 +416,225 @@ async function publishOne(flags, platform, file, extra, privateKey) {
372
416
  return { id: release.id, number: release.number, platform, rollout: result.rollout, status: "published" };
373
417
  }
374
418
 
419
+ // ---------- Flutter ----------
420
+
421
+ /** Arguments after a bare `--` go to `flutter build` unchanged. */
422
+ function passthroughArgs() {
423
+ const index = process.argv.indexOf("--");
424
+ return index >= 0 ? process.argv.slice(index + 1) : [];
425
+ }
426
+
427
+ function flutterOnly() {
428
+ if (!isFlutterProject()) fail("Run this from your Flutter project root (pubspec.yaml with a flutter section).");
429
+ }
430
+
431
+ /** Builds the store build and records what a code patch has to match. */
432
+ function flutterRelease(flags) {
433
+ flutterOnly();
434
+ const runtime = runtimeFor(flags, "android");
435
+ const buildId = newBuildId();
436
+ const versions = flutterVersionInfo();
437
+ log(`Building the Android store build ${runtime} (build ID ${buildId})…`);
438
+ const artifact = buildStore({ buildId, apk: Boolean(flags.apk), extra: passthroughArgs() });
439
+ const libs = libappByAbi(artifact);
440
+ if (!Object.keys(libs).length) fail(`${artifact} contains no libapp.so; is this a release build?`);
441
+ const record = {
442
+ platform: "android",
443
+ runtimeVersion: runtime,
444
+ buildId,
445
+ ...versions,
446
+ pluginsHash: pluginsHash(),
447
+ base: Object.fromEntries(Object.entries(libs).map(([abi, bytes]) => [abi, sha256Hex(bytes)])),
448
+ artifact: artifact.split("\\").join("/"),
449
+ createdAt: new Date().toISOString(),
450
+ };
451
+ const file = writeBaseRecord(record);
452
+ if (jsonMode) {
453
+ console.log(JSON.stringify({ ok: true, ...record, record: file }));
454
+ return;
455
+ }
456
+ log(`
457
+ Store build: ${artifact}
458
+ ABIs: ${Object.keys(record.base).join(", ")}
459
+ Recorded: ${file}
460
+
461
+ Next:
462
+ 1. Commit ${file}: CI needs it to build patches for this store build.
463
+ 2. Ship ${artifact} to Google Play (or your distribution channel) as usual.
464
+ 3. Later, change Dart code and run: fireota flutter patch android --app <appId> --private-key fireota-private.pem --message "What changed"`);
465
+ }
466
+
467
+ function stageDescriptor(dir, descriptor, flags) {
468
+ if (flags.assets && flags.assets !== true) {
469
+ if (!existsSync(String(flags.assets))) fail(`Assets folder not found: ${flags.assets}`);
470
+ cpSync(String(flags.assets), join(dir, "assets"), { recursive: true });
471
+ }
472
+ if (flags.config && flags.config !== true) {
473
+ const text = readFileSync(String(flags.config), "utf8");
474
+ try {
475
+ JSON.parse(text);
476
+ } catch {
477
+ fail(`${flags.config} is not valid JSON.`);
478
+ }
479
+ writeFileSync(join(dir, "config.json"), text);
480
+ }
481
+ writeFileSync(join(dir, "fireota.json"), `${JSON.stringify(descriptor, null, 2)}\n`);
482
+ }
483
+
484
+ function signingKey(flags, required) {
485
+ let key = null;
486
+ try {
487
+ key = loadPrivateKey(flags["private-key"] && flags["private-key"] !== true ? String(flags["private-key"]) : undefined);
488
+ } catch (error) {
489
+ fail(`Could not read the signing key: ${error instanceof Error ? error.message : error}`);
490
+ }
491
+ if (required && !key) {
492
+ fail("Code patches must be signed: pass --private-key fireota-private.pem (or set FIREOTA_SIGNING_KEY). Create keys with `fireota signing generate`.");
493
+ }
494
+ return key;
495
+ }
496
+
497
+ /** Compiles the current Dart code into a code patch for the recorded store build and publishes it. */
498
+ async function flutterPatch(flags) {
499
+ flutterOnly();
500
+ const runtime = runtimeFor(flags, "android");
501
+ const base = readBaseRecord(runtime);
502
+ if (!base) {
503
+ fail(`No store build is recorded for runtime ${runtime}. Run \`fireota flutter release android\` for the build you shipped (or pass --runtime).`);
504
+ }
505
+ const current = { ...flutterVersionInfo(), pluginsHash: pluginsHash() };
506
+ const problems = patchCompatibilityProblems(base, current);
507
+ if (problems.length && !flags.force) fail(`This patch would not fit store build ${runtime}:\n - ${problems.join("\n - ")}`);
508
+ const abis = String(flags.abis && flags.abis !== true ? flags.abis : "arm64-v8a").split(",").map((a) => a.trim()).filter(Boolean);
509
+ const missing = abis.filter((abi) => !base.base?.[abi]);
510
+ if (missing.length) fail(`The store build has no ${missing.join(", ")} code. Recorded ABIs: ${Object.keys(base.base ?? {}).join(", ")}`);
511
+ const dryRun = Boolean(flags["dry-run"]);
512
+ const key = signingKey(flags, !dryRun);
513
+
514
+ const buildId = newBuildId();
515
+ log(`Building code patch ${buildId} for store build ${runtime} (${abis.join(", ")})…`);
516
+ const apk = buildPatchApk({ buildId, abis, extra: passthroughArgs() });
517
+ const libs = libappByAbi(apk);
518
+ const dir = mkdtempSync(join(tmpdir(), "fireota-patch-"));
519
+ try {
520
+ for (const abi of abis) {
521
+ if (!libs[abi]) fail(`The patch build has no ${abi} libapp.so.`);
522
+ mkdirSync(join(dir, "lib", abi), { recursive: true });
523
+ writeFileSync(join(dir, "lib", abi, "libapp.so"), libs[abi]);
524
+ }
525
+ stageDescriptor(
526
+ dir,
527
+ {
528
+ version: 1,
529
+ kind: "flutter-aot",
530
+ runtimeVersion: runtime,
531
+ buildId,
532
+ flutterVersion: current.flutterVersion,
533
+ engineRevision: current.engineRevision,
534
+ pluginsHash: current.pluginsHash,
535
+ base: Object.fromEntries(abis.map((abi) => [abi, base.base[abi]])),
536
+ },
537
+ flags,
538
+ );
539
+ const file = flags.out && flags.out !== true ? String(flags.out) : join(dir, "..", `fireota-patch-${buildId}.fota`);
540
+ writeFileSync(file, packDirectory(dir));
541
+ log(`Packed ${file} (${(readFileSync(file).length / 1024 / 1024).toFixed(2)} MB).`);
542
+ if (dryRun) {
543
+ if (jsonMode) console.log(JSON.stringify({ ok: true, file, buildId }));
544
+ return;
545
+ }
546
+ const result = await publishOne(flags, "android", file, {}, key);
547
+ if (jsonMode) console.log(JSON.stringify({ ok: true, buildId, updates: [result] }));
548
+ } finally {
549
+ rmSync(dir, { recursive: true, force: true });
550
+ }
551
+ }
552
+
553
+ /** Publishes asset and remote-config updates. */
554
+ async function flutterAssets(flags) {
555
+ flutterOnly();
556
+ const platform = need(flags, "platform");
557
+ if (!["android", "ios", "all"].includes(platform)) fail("--platform must be android, ios or all");
558
+ if (!flags.assets && !flags.config) fail("Pass --assets <dir> and/or --config config.json.");
559
+ const key = signingKey(flags, false);
560
+ const platforms = platform === "all" ? ["android", "ios"] : [platform];
561
+ const results = [];
562
+ for (const p of platforms) {
563
+ const dir = mkdtempSync(join(tmpdir(), "fireota-assets-"));
564
+ try {
565
+ stageDescriptor(dir, { version: 1, kind: "flutter-assets", runtimeVersion: runtimeFor(flags, p) }, flags);
566
+ const file = join(dir, "..", `fireota-assets-${p}-${Date.now()}.fota`);
567
+ writeFileSync(file, packDirectory(dir));
568
+ results.push(await publishOne(flags, p, file, {}, key));
569
+ } finally {
570
+ rmSync(dir, { recursive: true, force: true });
571
+ }
572
+ }
573
+ if (jsonMode) console.log(JSON.stringify({ ok: true, updates: results }));
574
+ }
575
+
576
+ /** `fireota init` in a Flutter project. */
577
+ function flutterInit(flags) {
578
+ const appId = flags.app && flags.app !== true ? String(flags.app) : process.env.FIREOTA_APP_ID;
579
+ const serverUrl = flags["server-url"] && flags["server-url"] !== true ? String(flags["server-url"]) : process.env.FIREOTA_URL;
580
+ if (!appId || !serverUrl) fail("Pass --app <appId> and --server-url <url> (or set FIREOTA_APP_ID and FIREOTA_URL).");
581
+ const channel = String(flags.channel && flags.channel !== true ? flags.channel : "production");
582
+ const dry = Boolean(flags["dry-run"]);
583
+ const results = [];
584
+ const write = (file, text) => {
585
+ if (!dry) writeFileSync(file, text);
586
+ };
587
+
588
+ if (readPubspec().hasFireOTA) results.push(["✓", "fireota is already a dependency"]);
589
+ else if (dry) results.push(["+", "would run: flutter pub add fireota"]);
590
+ else {
591
+ try {
592
+ runFlutter(["pub", "add", "fireota"]);
593
+ results.push(["✓", "added fireota to pubspec.yaml"]);
594
+ } catch {
595
+ results.push(["!", "could not add fireota; run: flutter pub add fireota"]);
596
+ }
597
+ }
598
+
599
+ const configFile = join("lib", "fireota_config.dart");
600
+ if (existsSync(configFile) && !flags.force) results.push(["✓", `${configFile} already exists (use --force to rewrite)`]);
601
+ else {
602
+ write(configFile, flutterConfigSource({ appId, serverUrl: serverUrl.replace(/\/+$/, ""), channel }));
603
+ results.push(["+", `wrote ${configFile}`]);
604
+ }
605
+
606
+ const mainFile = join("lib", "main.dart");
607
+ const mainSource = existsSync(mainFile) ? readFileSync(mainFile, "utf8") : null;
608
+ const patchedMain = mainSource === null ? null : patchFlutterMain(mainSource);
609
+ if (patchedMain === null) results.push(["!", "add to main(): WidgetsFlutterBinding.ensureInitialized(); await FireOTA.init(fireotaConfig);"]);
610
+ else if (patchedMain === mainSource) results.push(["✓", "main.dart already starts FireOTA"]);
611
+ else {
612
+ write(mainFile, patchedMain);
613
+ results.push(["+", "main.dart starts FireOTA before runApp"]);
614
+ }
615
+
616
+ const activity = findFile(join("android", "app", "src", "main"), (name) => /^MainActivity\.(kt|java)$/.test(name));
617
+ const activitySource = activity ? readFileSync(activity, "utf8") : null;
618
+ const patchedActivity = activitySource === null ? null : patchFlutterActivity(activitySource, activity.endsWith(".kt"));
619
+ if (patchedActivity === null) results.push(["!", "make MainActivity extend dev.fireota.flutter.FireOTAFlutterActivity (needed for code patches)"]);
620
+ else if (patchedActivity === activitySource) results.push(["✓", "MainActivity already uses FireOTAFlutterActivity"]);
621
+ else {
622
+ write(activity, patchedActivity);
623
+ results.push(["+", `${activity} extends FireOTAFlutterActivity`]);
624
+ }
625
+
626
+ console.log(dry ? "\nFireOTA init (dry run — nothing written):\n" : "\nFireOTA is set up for Flutter:\n");
627
+ for (const [mark, text] of results) console.log(` ${mark} ${text}`);
628
+ console.log(`
629
+ Runtime version: ${storeVersion("android") ?? "unknown"} (pubspec.yaml version — bump it for every store release).
630
+
631
+ Next:
632
+ 1. Optional, Android code push: fireota signing generate, paste the public key into
633
+ lib/fireota_config.dart and the dashboard, and set allowCodePush: true.
634
+ 2. Build and record the store build: fireota flutter release android
635
+ 3. Ship a patch: fireota flutter patch android --app ${appId} --private-key fireota-private.pem --message "What changed"`);
636
+ }
637
+
375
638
  async function main() {
376
639
  const [command, sub, ...rest] = process.argv.slice(2);
377
640
  const { positional, flags } = parseArgs([sub, ...rest].filter((v) => v !== undefined));
@@ -385,6 +648,26 @@ async function main() {
385
648
  bundle(parseArgs(process.argv.slice(3)).flags);
386
649
  return;
387
650
  }
651
+ if (command === "flutter") {
652
+ const all = parseArgs(process.argv.slice(4, process.argv.includes("--") ? process.argv.indexOf("--") : undefined)).flags;
653
+ jsonMode = Boolean(all.json);
654
+ const target = process.argv[4] && !process.argv[4].startsWith("--") ? process.argv[4] : undefined;
655
+ const flagsAfterTarget = target ? parseArgs(process.argv.slice(5, process.argv.includes("--") ? process.argv.indexOf("--") : undefined)).flags : all;
656
+ if (sub === "release") {
657
+ if (target !== "android") fail("usage: fireota flutter release android [--apk] (iOS store builds need no recording: iOS gets asset updates only)");
658
+ return flutterRelease(flagsAfterTarget);
659
+ }
660
+ if (sub === "patch") {
661
+ if (target !== "android") fail("usage: fireota flutter patch android … (code patches are Android only)");
662
+ return flutterPatch(flagsAfterTarget);
663
+ }
664
+ if (sub === "assets") return flutterAssets(all);
665
+ fail("usage: fireota flutter <release|patch|assets> … (see fireota help)");
666
+ }
667
+ if (command === "init" && isFlutterProject()) {
668
+ flutterInit(parseArgs(process.argv.slice(3)).flags);
669
+ return;
670
+ }
388
671
  if (command === "init") {
389
672
  const all = parseArgs(process.argv.slice(3)).flags;
390
673
  const result = await runInit(all, { api, log, fail });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Flutter support: project detection, store builds ("bases") and code patches.
3
+ *
4
+ * A code patch is the compiled Dart library (libapp.so) of a new build. It only runs on the exact
5
+ * store build it was compiled against, so `fireota flutter release` records that build's
6
+ * fingerprints (SHA-256 of libapp.so per ABI, Flutter/engine version, native plugins) and
7
+ * `fireota flutter patch` refuses to build a patch that would not fit.
8
+ */
9
+ import { execFileSync } from "node:child_process";
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { basename, dirname, join } from "node:path";
13
+ import { inflateRawSync } from "node:zlib";
14
+
15
+ export const ABI_TARGETS = { "arm64-v8a": "android-arm64", "armeabi-v7a": "android-arm", "x86_64": "android-x64" };
16
+
17
+ export function isFlutterProject(root = ".") {
18
+ const file = join(root, "pubspec.yaml");
19
+ return existsSync(file) && /^\s*flutter\s*:/m.test(readFileSync(file, "utf8"));
20
+ }
21
+
22
+ /** name and version from pubspec.yaml; `version: 1.2.0+7` gives version 1.2.0, build 7. */
23
+ export function readPubspec(root = ".") {
24
+ const text = readFileSync(join(root, "pubspec.yaml"), "utf8");
25
+ const name = /^name:\s*["']?([\w-]+)/m.exec(text)?.[1] ?? "";
26
+ const raw = /^version:\s*["']?([^\s"'#]+)/m.exec(text)?.[1] ?? "";
27
+ const [version, build] = raw.split("+");
28
+ return { name, version: version ?? "", build: build ?? "", raw, hasFireOTA: /^\s{2}fireota\s*:/m.test(text) };
29
+ }
30
+
31
+ /** The runtime version the SDK reports by default: `<version>+<build>` of the store build. */
32
+ export function flutterRuntime(root = ".") {
33
+ const { version, build } = readPubspec(root);
34
+ if (!version) return undefined;
35
+ return build ? `${version}+${build}` : version;
36
+ }
37
+
38
+ /** Runs the flutter tool. On Windows flutter is a .bat file, which only runs through cmd.exe. */
39
+ export function flutter(args, { capture = false } = {}) {
40
+ const options = { stdio: capture ? ["ignore", "pipe", "inherit"] : "inherit", maxBuffer: 64 * 1024 * 1024 };
41
+ if (process.platform !== "win32") return execFileSync("flutter", args, options);
42
+ for (const arg of args) {
43
+ if (/["%^&|<>!\r\n]/.test(arg)) throw new Error(`Unsupported character in a flutter argument: ${arg}`);
44
+ }
45
+ const line = ["flutter", ...args].map((arg) => (/[\s,;=]/.test(arg) ? `"${arg}"` : arg)).join(" ");
46
+ return execFileSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `"${line}"`], { ...options, windowsVerbatimArguments: true });
47
+ }
48
+
49
+ /** Flutter framework and engine versions: a patch must be compiled by the same ones as its base. */
50
+ export function flutterVersionInfo() {
51
+ const info = JSON.parse(flutter(["--version", "--machine"], { capture: true }).toString());
52
+ return { flutterVersion: info.frameworkVersion ?? info.flutterVersion, engineRevision: info.engineRevision ?? "" };
53
+ }
54
+
55
+ /**
56
+ * Fingerprint of the plugins with Android/iOS native code. Adding, removing or upgrading one
57
+ * changes native code, which only a store release can ship.
58
+ */
59
+ export function pluginsHash(root = ".", platform = "android") {
60
+ const file = join(root, ".flutter-plugins-dependencies");
61
+ if (!existsSync(file)) return "";
62
+ const plugins = JSON.parse(readFileSync(file, "utf8")).plugins?.[platform] ?? [];
63
+ const ids = plugins
64
+ .filter((p) => p.native_build !== false)
65
+ .map((p) => `${p.name}:${basename(String(p.path ?? "").replace(/[\\/]+$/, ""))}`)
66
+ .sort();
67
+ return createHash("sha256").update(ids.join("\n")).digest("hex");
68
+ }
69
+
70
+ export function newBuildId() {
71
+ return `${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
72
+ }
73
+
74
+ /** Reads entries from a ZIP (APK or AAB) whose names match `test`. Stored and deflated entries only. */
75
+ export function readZipEntries(file, test) {
76
+ const zip = readFileSync(file);
77
+ let eocd = -1;
78
+ for (let i = zip.length - 22; i >= Math.max(0, zip.length - 65_557); i -= 1) {
79
+ if (zip.readUInt32LE(i) === 0x06054b50) {
80
+ eocd = i;
81
+ break;
82
+ }
83
+ }
84
+ if (eocd < 0) throw new Error(`${file} is not a ZIP archive.`);
85
+ const count = zip.readUInt16LE(eocd + 10);
86
+ let offset = zip.readUInt32LE(eocd + 16);
87
+ if (offset === 0xffffffff) throw new Error(`${file} is a ZIP64 archive, which is not supported.`);
88
+ const out = new Map();
89
+ for (let n = 0; n < count; n += 1) {
90
+ if (zip.readUInt32LE(offset) !== 0x02014b50) throw new Error(`${file}: corrupt central directory.`);
91
+ const method = zip.readUInt16LE(offset + 10);
92
+ const compressed = zip.readUInt32LE(offset + 20);
93
+ const nameLength = zip.readUInt16LE(offset + 28);
94
+ const extraLength = zip.readUInt16LE(offset + 30);
95
+ const commentLength = zip.readUInt16LE(offset + 32);
96
+ const local = zip.readUInt32LE(offset + 42);
97
+ const name = zip.toString("utf8", offset + 46, offset + 46 + nameLength);
98
+ offset += 46 + nameLength + extraLength + commentLength;
99
+ if (!test(name)) continue;
100
+ const start = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28);
101
+ const data = zip.subarray(start, start + compressed);
102
+ if (method === 0) out.set(name, Buffer.from(data));
103
+ else if (method === 8) out.set(name, inflateRawSync(data));
104
+ else throw new Error(`${file}: ${name} uses unsupported compression ${method}.`);
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** libapp.so per ABI from an APK (`lib/<abi>/libapp.so`) or AAB (`base/lib/<abi>/libapp.so`). */
110
+ export function libappByAbi(archive) {
111
+ const entries = readZipEntries(archive, (name) => /(^|\/)lib\/[^/]+\/libapp\.so$/.test(name));
112
+ const result = {};
113
+ for (const [name, bytes] of entries) result[name.split("/").at(-2)] = bytes;
114
+ return result;
115
+ }
116
+
117
+ export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
118
+
119
+ export function baseRecordPath(runtime, root = ".") {
120
+ return join(root, ".fireota", "flutter", `android-${runtime.replace(/[^\w.+-]/g, "_")}.json`);
121
+ }
122
+
123
+ export function readBaseRecord(runtime, root = ".") {
124
+ const file = baseRecordPath(runtime, root);
125
+ return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
126
+ }
127
+
128
+ export function writeBaseRecord(record, root = ".") {
129
+ const file = baseRecordPath(record.runtimeVersion, root);
130
+ mkdirSync(dirname(file), { recursive: true });
131
+ writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
132
+ return file;
133
+ }
134
+
135
+ /** Why a patch built now would not fit its base, or an empty list. */
136
+ export function patchCompatibilityProblems(base, current) {
137
+ const problems = [];
138
+ if (base.flutterVersion !== current.flutterVersion) {
139
+ problems.push(`Flutter ${current.flutterVersion} differs from the store build's ${base.flutterVersion}.`);
140
+ }
141
+ if (base.engineRevision && current.engineRevision && base.engineRevision !== current.engineRevision) {
142
+ problems.push("The Flutter engine differs from the store build's.");
143
+ }
144
+ if (base.pluginsHash && current.pluginsHash && base.pluginsHash !== current.pluginsHash) {
145
+ problems.push("Plugins with native code were added, removed or upgraded since the store build; ship a store release.");
146
+ }
147
+ return problems;
148
+ }
149
+
150
+ export function buildStore({ buildId, apk = false, extra = [] }) {
151
+ flutter(["build", apk ? "apk" : "appbundle", "--release", `--dart-define=FIREOTA_BUILD_ID=${buildId}`, ...extra]);
152
+ return apk ? join("build", "app", "outputs", "flutter-apk", "app-release.apk") : join("build", "app", "outputs", "bundle", "release", "app-release.aab");
153
+ }
154
+
155
+ export function buildPatchApk({ buildId, abis, extra = [] }) {
156
+ const targets = abis.map((abi) => ABI_TARGETS[abi]).filter(Boolean);
157
+ flutter(["build", "apk", "--release", `--target-platform=${targets.join(",")}`, `--dart-define=FIREOTA_BUILD_ID=${buildId}`, ...extra]);
158
+ return join("build", "app", "outputs", "flutter-apk", "app-release.apk");
159
+ }
160
+
161
+ // ---------- fireota init for Flutter ----------
162
+
163
+ export function flutterConfigSource({ appId, serverUrl, channel }) {
164
+ return `// Generated by \`fireota init\`. Safe to edit and commit.
165
+ import 'package:fireota/fireota.dart';
166
+
167
+ const fireotaConfig = FireOTAConfig(
168
+ appId: '${appId}',
169
+ serverUrl: '${serverUrl}',
170
+ channel: '${channel}',
171
+ // Dart code patches on Android. Read "Store policy" in the fireota README first.
172
+ allowCodePush: false,
173
+ // Paste fireota-public.pem here (fireota signing generate). Required for code patches.
174
+ codeSigningCertificate: null,
175
+ );
176
+ `;
177
+ }
178
+
179
+ /** Makes main() async and starts FireOTA before runApp. Returns null when it cannot patch safely. */
180
+ export function patchFlutterMain(source, configImport = "fireota_config.dart") {
181
+ if (source.includes("FireOTA.init(")) return source;
182
+ // Keep the file's own line endings: `flutter create` writes CRLF on Windows.
183
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
184
+ const main = /^[ \t]*(?:Future<void>\s+|void\s+)?main\s*\(\s*\)\s*(?:async\s*)?\{/m.exec(source);
185
+ if (!main) return null;
186
+ const body = `Future<void> main() async {${eol} WidgetsFlutterBinding.ensureInitialized();${eol} await FireOTA.init(fireotaConfig);`;
187
+ let next = source.slice(0, main.index) + body + source.slice(main.index + main[0].length);
188
+ const imports = ["import 'package:fireota/fireota.dart';", `import '${configImport}';`];
189
+ if (!/import\s+['"]package:flutter\/(material|widgets|cupertino)\.dart['"]/.test(next)) imports.unshift("import 'package:flutter/widgets.dart';");
190
+ const lastImport = [...next.matchAll(/^import\s+[^;\r\n]+;[ \t]*(?=\r?$)/gm)].at(-1);
191
+ if (lastImport) {
192
+ const at = lastImport.index + lastImport[0].length;
193
+ next = `${next.slice(0, at)}${eol}${imports.join(eol)}${next.slice(at)}`;
194
+ } else {
195
+ next = `${imports.join(eol)}${eol}${eol}${next}`;
196
+ }
197
+ return next;
198
+ }
199
+
200
+ /** MainActivity extends FireOTAFlutterActivity, which starts the engine with the installed patch. */
201
+ export function patchFlutterActivity(source, isKotlin) {
202
+ if (source.includes("FireOTAFlutterActivity")) return source;
203
+ if (isKotlin) {
204
+ if (!/:\s*FlutterActivity\(\)/.test(source)) return null;
205
+ return source
206
+ .replace(/import io\.flutter\.embedding\.android\.FlutterActivity(?=[ \t]*\r?\n)/, "import dev.fireota.flutter.FireOTAFlutterActivity")
207
+ .replace(/:\s*FlutterActivity\(\)/, ": FireOTAFlutterActivity()");
208
+ }
209
+ if (!/extends\s+FlutterActivity\b/.test(source)) return null;
210
+ return source
211
+ .replace(/import io\.flutter\.embedding\.android\.FlutterActivity;/, "import dev.fireota.flutter.FireOTAFlutterActivity;")
212
+ .replace(/extends\s+FlutterActivity\b/, "extends FireOTAFlutterActivity");
213
+ }
package/lib/project.mjs CHANGED
@@ -1,7 +1,9 @@
1
- /** Reads facts about the React Native project the CLI runs in. */
1
+ /** Reads facts about the React Native or Flutter project the CLI runs in. */
2
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
+ import { flutterRuntime, isFlutterProject } from "./flutter.mjs";
6
+
5
7
  /** android/app/build.gradle(.kts): versionName "1.3.3" / versionName = "1.3.3" */
6
8
  export function androidVersionName(root = ".") {
7
9
  for (const file of ["android/app/build.gradle", "android/app/build.gradle.kts"]) {
@@ -57,6 +59,8 @@ export function expoVersion(root = ".") {
57
59
 
58
60
  /** The app's store version for a platform — FireOTA's default runtime version. */
59
61
  export function storeVersion(platform, root = ".") {
62
+ // Flutter: pubspec.yaml `version: 1.2.0+7` sets both stores' version, and the SDK reports "1.2.0+7".
63
+ if (isFlutterProject(root)) return flutterRuntime(root);
60
64
  const native = platform === "ios" ? iosMarketingVersion(root) : androidVersionName(root);
61
65
  // Expo projects without android/ or ios/ yet (Continuous Native Generation): use app.json.
62
66
  return native ?? (isExpoProject(root) ? expoVersion(root) : undefined);
package/package.json CHANGED
@@ -1,40 +1,40 @@
1
- {
2
- "name": "@fireota/cli",
3
- "version": "0.5.0",
4
- "description": "Build FireOTA update packages and publish React Native OTA releases from a terminal or CI.",
5
- "license": "MIT",
6
- "type": "module",
7
- "bin": {
8
- "fireota": "bin/fireota.mjs"
9
- },
10
- "files": [
11
- "bin",
12
- "lib",
13
- "README.md",
14
- "LICENSE"
15
- ],
16
- "engines": {
17
- "node": ">=18.17"
18
- },
19
- "keywords": [
20
- "react-native",
21
- "ota",
22
- "over-the-air",
23
- "fireota",
24
- "cli",
25
- "ci"
26
- ],
27
- "publishConfig": {
28
- "access": "public"
29
- },
30
- "author": "FireOTA",
31
- "repository": {
32
- "type": "git",
33
- "url": "git+https://github.com/Sujay-Dynamisch/FireOTA.git",
34
- "directory": "packages/cli"
35
- },
36
- "homepage": "https://github.com/Sujay-Dynamisch/FireOTA/tree/main/packages/cli#readme",
37
- "bugs": {
38
- "url": "https://github.com/Sujay-Dynamisch/FireOTA/issues"
39
- }
40
- }
1
+ {
2
+ "name": "@fireota/cli",
3
+ "version": "0.7.0",
4
+ "description": "Build FireOTA update packages and publish React Native OTA releases from a terminal or CI.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "fireota": "bin/fireota.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.17"
18
+ },
19
+ "keywords": [
20
+ "react-native",
21
+ "ota",
22
+ "over-the-air",
23
+ "fireota",
24
+ "cli",
25
+ "ci"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "author": "FireOTA",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/Sujay-Dynamisch/FireOTA.git",
34
+ "directory": "packages/cli"
35
+ },
36
+ "homepage": "https://github.com/Sujay-Dynamisch/FireOTA/tree/main/packages/cli#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/Sujay-Dynamisch/FireOTA/issues"
39
+ }
40
+ }