@fireota/cli 0.1.1 → 0.2.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,92 @@
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 publish --app <appId> --platform <android|ios|all> --runtime <x.y.z> --message "…"
67
+ [--channel production] [--rollout 10] [--version <label>] [--mandatory] [--native ">=1.4.0"]
68
+ [--file update.fota] [--private-key key.pem] [--json]
54
69
  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
70
+ fireota whoami
71
+ fireota signing generate [--out ./fireota-keys]
72
+ fireota release create --app <appId> --platform <p> --runtime <r> [--message m] [--version v] [--channel c]
73
+ fireota release upload <releaseId> --file update.fota [--private-key key.pem]
59
74
  fireota release publish <releaseId> [--rollout 100]
60
75
  fireota release rollout <releaseId> --percentage <0-100>
61
76
  fireota release pause <releaseId>
62
77
  fireota release rollback <releaseId> --reason "…" [--target <releaseId>]
63
- fireota releases [--app <appId>]
78
+ fireota releases [--app <appId>] [--json]
64
79
  ```
65
80
 
66
- ## GitHub Actions
81
+ ## CI/CD
82
+
83
+ 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**.
84
+
85
+ GitHub Actions:
67
86
 
68
87
  ```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
88
+ - run: npx fireota publish --app ${{ vars.FIREOTA_APP_ID }} --platform android --runtime 1.3.0 --channel beta --message "${{ github.event.head_commit.message }}"
70
89
  env:
71
90
  FIREOTA_URL: ${{ vars.FIREOTA_URL }}
72
91
  FIREOTA_API_KEY: ${{ secrets.FIREOTA_API_KEY }}
73
92
  ```
74
93
 
94
+ Jenkins (declarative):
95
+
96
+ ```groovy
97
+ environment {
98
+ FIREOTA_URL = 'https://ota.example.com'
99
+ FIREOTA_API_KEY = credentials('fireota-api-key')
100
+ }
101
+ steps {
102
+ sh 'npm ci && npx fireota publish --app "$APP_ID" --platform android --runtime 1.3.0 --message "$(git log -1 --pretty=%s)"'
103
+ }
104
+ ```
105
+
106
+ Bitbucket Pipelines:
107
+
108
+ ```yaml
109
+ script:
110
+ - npm ci
111
+ - npx fireota publish --app "$FIREOTA_APP_ID" --platform android --runtime 1.3.0 --message "$(git log -1 --pretty=%s)"
112
+ ```
113
+
75
114
  ## License
76
115
 
77
116
  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,41 @@ 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";
14
17
  import { packDirectory } from "../lib/package.mjs";
18
+ import { generateKeys, loadPrivateKey, signUpdate } from "../lib/signing.mjs";
15
19
 
16
20
  const HELP = `FireOTA CLI
17
21
 
18
22
  Usage:
23
+ fireota publish --app <appId> --platform <android|ios|all> --runtime <x.y.z> --message "What changed"
24
+ [--channel production] [--rollout 10] [--version <label>] [--mandatory] [--native ">=1.4.0"]
25
+ [--file update.fota] [--private-key fireota-private.pem] [--json]
26
+ Builds (unless --file), uploads to your Firebase Storage and publishes.
27
+ --platform all publishes Android + iOS as one update group.
19
28
  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
29
+ fireota whoami Shows the workspace, key role and apps behind FIREOTA_API_KEY.
30
+ fireota signing generate [--out ./fireota-keys] Creates an RSA key pair for code signing.
31
+ fireota release create --app <appId> --platform <p> --runtime <r> [--message m] [--version v] [--channel c]
32
+ fireota release upload <releaseId> --file update.fota [--private-key key.pem]
25
33
  fireota release publish <releaseId> [--rollout 100]
26
34
  fireota release rollout <releaseId> --percentage <0-100>
27
35
  fireota release pause <releaseId>
28
36
  fireota release rollback <releaseId> --reason "..." [--target <releaseId>]
29
- fireota releases [--app <appId>]
37
+ fireota releases [--app <appId>] [--json]
30
38
 
31
- Environment: FIREOTA_URL, FIREOTA_API_KEY. Run from your React Native project root.`;
39
+ Updates are identified by their ID (like Expo): --version is only an optional label, and
40
+ --runtime must equal the runtime version of the store build the update is for.
41
+
42
+ Environment: FIREOTA_URL, FIREOTA_API_KEY (required); FIREOTA_APP_ID, FIREOTA_CHANNEL,
43
+ FIREOTA_RUNTIME, FIREOTA_SIGNING_KEY / FIREOTA_SIGNING_KEY_PATH (optional).
44
+ Works on GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps, CircleCI, Bitrise and any
45
+ other CI: commit, branch and build number are detected automatically.
46
+ Exit codes: 0 success, 1 error.`;
47
+
48
+ let jsonMode = false;
49
+ /** Human progress goes to stderr in --json mode so stdout stays machine-readable. */
50
+ const log = (...args) => (jsonMode ? console.error(...args) : console.log(...args));
32
51
 
33
52
  function parseArgs(argv) {
34
53
  const positional = [];
@@ -49,13 +68,14 @@ function parseArgs(argv) {
49
68
  }
50
69
 
51
70
  function fail(message) {
71
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: message }));
52
72
  console.error(`fireota: ${message}`);
53
73
  process.exit(1);
54
74
  }
55
75
 
56
- function need(flags, key) {
57
- const value = flags[key];
58
- if (value === undefined || value === true) fail(`--${key} is required`);
76
+ function need(flags, key, envName) {
77
+ const value = flags[key] ?? (envName ? process.env[envName] : undefined);
78
+ if (value === undefined || value === true || value === "") fail(`--${key} is required${envName ? ` (or set ${envName})` : ""}`);
59
79
  return String(value);
60
80
  }
61
81
 
@@ -63,6 +83,12 @@ function sha256(buffer) {
63
83
  return createHash("sha256").update(buffer).digest("hex");
64
84
  }
65
85
 
86
+ /** Deterministic RFC-4122-shaped id, so a retried CI job reuses the same update group. */
87
+ function groupIdFrom(seed) {
88
+ const h = sha256(Buffer.from(seed));
89
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
90
+ }
91
+
66
92
  // ---------- packaging ----------
67
93
 
68
94
  function hermesEnabled(platform, mode) {
@@ -88,8 +114,8 @@ function hermesc() {
88
114
  }
89
115
 
90
116
  /** 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");
117
+ function bundle(flags, platformOverride) {
118
+ const platform = platformOverride ?? need(flags, "platform");
93
119
  if (!["android", "ios"].includes(platform)) fail("--platform must be android or ios");
94
120
  if (!existsSync("package.json") || !existsSync(join("node_modules", "react-native"))) {
95
121
  fail("Run this from your React Native project root (react-native must be installed).");
@@ -100,25 +126,27 @@ function bundle(flags) {
100
126
  const out = join(work, "package");
101
127
  try {
102
128
  const jsOut = join(work, "bundle.js");
103
- console.log(`Bundling ${entry} for ${platform}…`);
129
+ log(`Bundling ${entry} for ${platform}…`);
104
130
  // Run the project's own React Native CLI through Node: no shell, same on every OS.
105
131
  execFileSync(
106
132
  process.execPath,
107
133
  [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" },
134
+ { stdio: jsonMode ? ["ignore", "ignore", "inherit"] : "inherit" },
109
135
  );
110
136
  // The bundler only creates the assets folder when the app has images.
111
137
  mkdirSync(out, { recursive: true });
112
138
  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" });
139
+ log("Compiling to Hermes bytecode…");
140
+ execFileSync(hermesc(), ["-O", "-w", "-emit-binary", "-out", join(out, bundleName), jsOut], {
141
+ stdio: jsonMode ? ["ignore", "ignore", "inherit"] : "inherit",
142
+ });
115
143
  } else {
116
144
  writeFileSync(join(out, bundleName), readFileSync(jsOut));
117
145
  }
118
146
  const packed = packDirectory(out);
119
- const target = resolve(String(flags.out ?? `fireota-${platform}.fota`));
147
+ const target = resolve(String(flags.out && !platformOverride ? flags.out : `fireota-${platform}.fota`));
120
148
  writeFileSync(target, packed);
121
- console.log(`Package: ${target}\nSize: ${(packed.length / 1024 / 1024).toFixed(2)} MB\nSHA-256: ${sha256(packed)}`);
149
+ log(`Package: ${target}\nSize: ${(packed.length / 1024 / 1024).toFixed(2)} MB\nSHA-256: ${sha256(packed)}`);
122
150
  return target;
123
151
  } finally {
124
152
  rmSync(work, { recursive: true, force: true });
@@ -132,13 +160,18 @@ async function api(method, path, body, extraHeaders = {}) {
132
160
  const key = process.env.FIREOTA_API_KEY;
133
161
  if (!base) fail("Set FIREOTA_URL to your FireOTA deployment URL.");
134
162
  if (!key) fail("Set FIREOTA_API_KEY (Settings -> API keys).");
135
- const headers = { authorization: `Bearer ${key}`, ...extraHeaders };
163
+ const headers = { authorization: `Bearer ${key}`, "user-agent": "fireota-cli", ...extraHeaders };
136
164
  let payload = body;
137
165
  if (body && !(body instanceof FormData)) {
138
166
  headers["content-type"] = "application/json";
139
167
  payload = JSON.stringify(body);
140
168
  }
141
- const response = await fetch(`${base.replace(/\/+$/, "")}/api/public/v1${path}`, { method, headers, body: payload });
169
+ let response;
170
+ try {
171
+ response = await fetch(`${base.replace(/\/+$/, "")}/api/public/v1${path}`, { method, headers, body: payload });
172
+ } catch (error) {
173
+ fail(`Could not reach ${base}: ${error instanceof Error ? error.message : error}`);
174
+ }
142
175
  let json;
143
176
  try {
144
177
  json = await response.json();
@@ -149,34 +182,44 @@ async function api(method, path, body, extraHeaders = {}) {
149
182
  return json.data;
150
183
  }
151
184
 
152
- async function createRelease(flags) {
185
+ async function createRelease(flags, platform, extra = {}) {
186
+ const ci = detectCi();
187
+ const message = flags.message ?? flags.notes;
153
188
  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) } : {}),
189
+ appId: need(flags, "app", "FIREOTA_APP_ID"),
190
+ channel: String(flags.channel ?? process.env.FIREOTA_CHANNEL ?? "production"),
191
+ platform,
192
+ runtimeVersion: need(flags, "runtime", "FIREOTA_RUNTIME"),
193
+ ...(flags.version && flags.version !== true ? { version: String(flags.version) } : {}),
194
+ ...(message && message !== true ? { releaseNotes: String(message) } : {}),
160
195
  ...(flags.mandatory ? { mandatory: true } : {}),
161
196
  ...(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 } : {}),
197
+ ...(ci.gitCommitSha ? { gitCommitSha: ci.gitCommitSha.slice(0, 64) } : {}),
198
+ ...(ci.gitBranch ? { gitBranch: ci.gitBranch.slice(0, 120) } : {}),
199
+ ...(ci.buildNumber ? { buildNumber: String(ci.buildNumber).slice(0, 40) } : {}),
200
+ ...extra,
164
201
  };
165
202
  // Same inputs -> same release, so a retried CI job never creates duplicates.
166
203
  const idempotencyKey = sha256(Buffer.from(JSON.stringify(body)));
167
204
  const release = await api("POST", "/releases", body, { "idempotency-key": idempotencyKey });
168
- console.log(`Release #${release.number} (${release.id}) — ${release.status}`);
205
+ log(`Update #${release.number} (${release.id}) — ${release.status} [${platform}, ${ci.provider}]`);
169
206
  return release;
170
207
  }
171
208
 
172
- async function uploadRelease(releaseId, file) {
209
+ async function uploadRelease(releaseId, file, signing) {
173
210
  if (!existsSync(file)) fail(`File not found: ${file}`);
174
211
  const bytes = readFileSync(file);
212
+ const hash = sha256(bytes);
175
213
  const form = new FormData();
176
214
  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}`);
215
+ const headers = { "x-bundle-sha256": hash };
216
+ if (signing?.key) {
217
+ headers["x-fireota-signature"] = signUpdate(signing.key, { runtimeVersion: signing.runtimeVersion, platform: signing.platform, sha256: hash });
218
+ log("Signed with your code signing key.");
219
+ }
220
+ log(`Uploading ${(bytes.length / 1024 / 1024).toFixed(2)} MB…`);
221
+ const result = await api("POST", `/releases/${releaseId}/bundle`, form, headers);
222
+ log(`Verified in your Firebase Storage. SHA-256 ${result.checksum}`);
180
223
  return result;
181
224
  }
182
225
 
@@ -184,9 +227,28 @@ async function releaseAction(releaseId, body) {
184
227
  return api("POST", `/releases/${releaseId}/publish`, body);
185
228
  }
186
229
 
230
+ /** Create → upload → publish, skipping steps a retried CI job already completed. */
231
+ async function publishOne(flags, platform, file, extra, privateKey) {
232
+ const release = await createRelease(flags, platform, extra);
233
+ if (["draft", "failed", "uploading"].includes(release.status)) {
234
+ await uploadRelease(release.id, file, privateKey ? { key: privateKey, runtimeVersion: need(flags, "runtime", "FIREOTA_RUNTIME"), platform } : null);
235
+ } else {
236
+ log(`Bundle already uploaded (status ${release.status}); skipping upload.`);
237
+ }
238
+ const rollout = Number(flags.rollout ?? 100);
239
+ if (release.status === "published") {
240
+ log("Already published; nothing to do.");
241
+ return { id: release.id, number: release.number, platform, rollout: null, status: "published" };
242
+ }
243
+ const result = await releaseAction(release.id, { action: "publish", rolloutPercentage: rollout });
244
+ log(`Published update #${release.number} (${platform}) at ${result.rollout}%.`);
245
+ return { id: release.id, number: release.number, platform, rollout: result.rollout, status: "published" };
246
+ }
247
+
187
248
  async function main() {
188
249
  const [command, sub, ...rest] = process.argv.slice(2);
189
250
  const { positional, flags } = parseArgs([sub, ...rest].filter((v) => v !== undefined));
251
+ jsonMode = Boolean(flags.json);
190
252
 
191
253
  if (!command || command === "help" || command === "--help" || flags.help) {
192
254
  console.log(HELP);
@@ -196,53 +258,116 @@ async function main() {
196
258
  bundle(parseArgs(process.argv.slice(3)).flags);
197
259
  return;
198
260
  }
261
+ if (command === "whoami") {
262
+ const me = await api("GET", "/me");
263
+ if (jsonMode) {
264
+ console.log(JSON.stringify({ ok: true, ...me }));
265
+ return;
266
+ }
267
+ console.log(`Workspace: ${me.organization.name} (${me.organization.slug})\nKey: ${me.key.label} — acts as ${me.key.role}\nApps:`);
268
+ for (const app of me.apps) {
269
+ console.log(` ${app.name}\t${app.id}\truntime ${app.runtimeVersion}${app.requiresSignedUpdates ? "\tsigned updates required" : ""}`);
270
+ }
271
+ return;
272
+ }
273
+ if (command === "signing") {
274
+ if (positional[0] !== "generate") fail("usage: fireota signing generate [--out ./fireota-keys]");
275
+ const keys = generateKeys(flags.out && flags.out !== true ? String(flags.out) : undefined);
276
+ console.log(`Created ${keys.privatePath} (keep secret — never commit it)\nCreated ${keys.publicPath}
277
+
278
+ Next:
279
+ 1. Dashboard → Applications → your app → Code signing: paste ${keys.publicPath}
280
+ 2. In the app: FireOTA.configure({ …, codeSigningCertificate: <contents of fireota-public.pem> })
281
+ 3. Publish with --private-key ${keys.privatePath} (in CI: store the file as the FIREOTA_SIGNING_KEY secret)`);
282
+ return;
283
+ }
199
284
  if (command === "releases") {
200
- const query = flags.app ? `?appId=${encodeURIComponent(String(flags.app))}` : "";
285
+ const app = flags.app ?? process.env.FIREOTA_APP_ID;
286
+ const query = app ? `?appId=${encodeURIComponent(String(app))}` : "";
201
287
  const rows = await api("GET", `/releases${query}`);
288
+ if (jsonMode) {
289
+ console.log(JSON.stringify({ ok: true, releases: rows }));
290
+ return;
291
+ }
202
292
  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}`);
293
+ 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
294
  }
205
295
  return;
206
296
  }
207
297
  if (command === "publish") {
208
298
  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}%.`);
299
+ jsonMode = Boolean(all.json);
300
+ const platform = need(all, "platform");
301
+ if (!["android", "ios", "all"].includes(platform)) fail("--platform must be android, ios or all");
302
+ if (platform === "all" && all.file) fail("--file cannot be combined with --platform all (each platform needs its own package).");
303
+ let privateKey = null;
304
+ try {
305
+ privateKey = loadPrivateKey(all["private-key"] && all["private-key"] !== true ? String(all["private-key"]) : undefined);
306
+ } catch (error) {
307
+ fail(`Could not read the signing key: ${error instanceof Error ? error.message : error}`);
308
+ }
309
+
310
+ const platforms = platform === "all" ? ["android", "ios"] : [platform];
311
+ const files = Object.fromEntries(platforms.map((p) => [p, all.file ? String(all.file) : bundle(all, p)]));
312
+ // Android + iOS built from the same commit form one update group (Expo-style).
313
+ const extra =
314
+ platform === "all"
315
+ ? { groupId: groupIdFrom(platforms.map((p) => sha256(readFileSync(files[p]))).join(":") + String(all.runtime ?? process.env.FIREOTA_RUNTIME)) }
316
+ : {};
317
+ const results = [];
318
+ for (const p of platforms) results.push(await publishOne(all, p, files[p], extra, privateKey));
319
+ if (jsonMode) console.log(JSON.stringify({ ok: true, groupId: extra.groupId ?? null, updates: results }));
214
320
  return;
215
321
  }
216
322
  if (command === "release") {
217
323
  const id = positional[1];
218
324
  switch (positional[0]) {
219
- case "create":
220
- await createRelease(flags);
325
+ case "create": {
326
+ const r = await createRelease(flags, need(flags, "platform"));
327
+ if (jsonMode) console.log(JSON.stringify({ ok: true, ...r }));
221
328
  return;
222
- case "upload":
223
- await uploadRelease(id ?? fail("release id required"), need(flags, "file"));
329
+ }
330
+ case "upload": {
331
+ let privateKey = null;
332
+ try {
333
+ privateKey = loadPrivateKey(flags["private-key"] && flags["private-key"] !== true ? String(flags["private-key"]) : undefined);
334
+ } catch (error) {
335
+ fail(`Could not read the signing key: ${error instanceof Error ? error.message : error}`);
336
+ }
337
+ let signing = null;
338
+ if (privateKey) {
339
+ const list = await api("GET", "/releases?limit=200");
340
+ const target = list.find((r) => r.id === id);
341
+ if (!target) fail("Release not found (needed to sign it).");
342
+ signing = { key: privateKey, runtimeVersion: target.runtime_version, platform: target.platform };
343
+ }
344
+ const r = await uploadRelease(id ?? fail("release id required"), need(flags, "file"), signing);
345
+ if (jsonMode) console.log(JSON.stringify({ ok: true, ...r }));
224
346
  return;
347
+ }
225
348
  case "publish": {
226
349
  const r = await releaseAction(id ?? fail("release id required"), { action: "publish", rolloutPercentage: Number(flags.rollout ?? 100) });
227
- console.log(`Published at ${r.rollout}%.`);
350
+ jsonMode ? console.log(JSON.stringify({ ok: true, ...r })) : console.log(`Published at ${r.rollout}%.`);
228
351
  return;
229
352
  }
230
353
  case "rollout": {
231
354
  const r = await releaseAction(id ?? fail("release id required"), { action: "rollout", rolloutPercentage: Number(need(flags, "percentage")) });
232
- console.log(`Rollout set to ${r.rollout}%.`);
355
+ jsonMode ? console.log(JSON.stringify({ ok: true, ...r })) : console.log(`Rollout set to ${r.rollout}%.`);
233
356
  return;
234
357
  }
235
- case "pause":
236
- await releaseAction(id ?? fail("release id required"), { action: "pause" });
237
- console.log("Paused.");
358
+ case "pause": {
359
+ const r = await releaseAction(id ?? fail("release id required"), { action: "pause" });
360
+ jsonMode ? console.log(JSON.stringify({ ok: true, ...r })) : console.log("Paused.");
238
361
  return;
362
+ }
239
363
  case "rollback": {
240
364
  const r = await releaseAction(id ?? fail("release id required"), {
241
365
  action: "rollback",
242
366
  reason: need(flags, "reason"),
243
367
  ...(flags.target ? { targetReleaseId: String(flags.target) } : {}),
244
368
  });
245
- console.log(r.restoredReleaseId ? `Rolled back; now serving ${r.restoredReleaseId}.` : "Rolled back; channel has no active release.");
369
+ if (jsonMode) console.log(JSON.stringify({ ok: true, ...r }));
370
+ else console.log(r.restoredReleaseId ? `Rolled back; now serving ${r.restoredReleaseId}.` : "Rolled back; channel has no active release.");
246
371
  return;
247
372
  }
248
373
  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
+ }
@@ -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.2.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",