@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 +53 -14
- package/bin/fireota.mjs +180 -55
- package/lib/ci.mjs +53 -0
- package/lib/signing.mjs +48 -0
- package/package.json +1 -1
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 (
|
|
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
|
|
43
|
-
--
|
|
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
|
|
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
|
-
- `--
|
|
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
|
|
56
|
-
|
|
57
|
-
fireota release create --app <appId> --platform <p> --
|
|
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
|
-
##
|
|
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 --
|
|
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
|
|
6
|
-
* FIREOTA_API_KEY
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
fireota release
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
execFileSync(hermesc(), ["-O", "-w", "-emit-binary", "-out", join(out, bundleName), jsOut], {
|
|
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
|
|
147
|
+
const target = resolve(String(flags.out && !platformOverride ? flags.out : `fireota-${platform}.fota`));
|
|
120
148
|
writeFileSync(target, packed);
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
...(
|
|
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
|
-
...(
|
|
163
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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(
|
|
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
|
+
}
|
package/lib/signing.mjs
ADDED
|
@@ -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
|
+
}
|