@rollbird/cli
v0.2.0
Published
Command-line tools for shipping React Native OTA updates with Rollbird.
Maintainers
Readme
@rollbird/cli
The rollbird command: detect a React Native project, wire it up to a
Rollbird project, and ship signed OTA releases from your machine or CI.
Bundled to a single ESM file with tsup, requires Node ≥ 20.
Install
npm i -D @rollbird/cliA local install puts rollbird in node_modules/.bin, so npx rollbird
<command> picks it up without any global install. (A global install —
npm i -g @rollbird/cli — works the same way, for use outside any one
project.)
Quickstart
npx rollbird login # confirm the device code in your browser (or set ROLLBIRD_TOKEN in CI, see below)
npx rollbird init # detect the project, link/create it, wire native config
npx rollbird release --productionIn a terminal each command renders a timeline: every step spins while it runs and settles with its result and elapsed time.
┌ rollbird release · fieldkit → production
│
◇ Bundled iOS 966 KB · 12.4s
│
◇ Bundled Android 968 KB · 11.8s
│
◇ Signed 2 bundles
│
◇ Uploaded 1.9 MB · 2.1s
│
◇ Published Release #4 · 100% rollout
│
└ Live in production
https://app.rollbird.dev/p/fieldkit/r/4In CI, when output is piped, or with --json, the same steps print as plain
lines with no animation or timings (✓ Bundled iOS 966 KB), and errors as
✗ message followed by the hint.
init prints a snippet to wrap your app entry point:
export default Rollbird.wrap({ projectKey: "pk_..." })(App);See @rollbird/react-native for what
Rollbird.wrap does.
Commands
rollbird login
Authenticates the CLI via the device-authorization flow. In a terminal it
prints the one-time code (and copies it to your clipboard when pbcopy,
clip, wl-copy, xclip or xsel is available), then offers Press Enter
to open https://app.rollbird.dev/device?code=WXYZ-1234 — the device page
arrives with the code filled in, and you confirm it after checking it matches
your terminal. The CLI polls the whole time, so confirming from any browser
or device works without pressing Enter. Writes
~/.config/rollbird/credentials.json (0600). Each org you log in to is kept:
logging in to a second org adds it rather than replacing the first, and the
latest login becomes the default.
Without a TTY (CI, piped output, --json) it never reads stdin, copies, or
opens anything: it prints Open https://app.rollbird.dev/device and enter the
code: WXYZ-1234 and polls.
| Flag | Description |
| --- | --- |
| --token <token> | API token — CI escape hatch, skips the device flow entirely |
| --org <orgId> | Organization id (optional, paired with --token; the API resolves org from the token itself otherwise) |
You will not normally need this in CI — see Authentication below.
rollbird init
Detects the React Native project in the current directory (ios/,
android/, Hermes, bundle/application ids), links it to a Rollbird project
(or creates one), generates a signing key pair for it server-side, writes the
channel and public key into your native config, adds @rollbird/react-native
- the pinned
@hot-updater/react-nativetopackage.json(installing them, unless--no-install), wires the native entry points (MainApplication'sgetJSBundleFile,AppDelegate'sbundleURL) with Hot Updater's own transformers, embeds each platform's native fingerprint (HOT_UPDATER_FINGERPRINT_HASH/hot_updater_fingerprint_hash), and writesrollbird.json. Safe to re-run — every write is idempotent — and re-run it whenever native code changes, then rebuild: devices only receive releases whose fingerprint matches the one their build carries.releasewarns when the fingerprint it computes differs from the embedded one.
| Flag | Default | Description |
| --- | --- | --- |
| --name <name> | package.json name | Project name, only used when creating a new Rollbird project |
| --channel <channel> | production | Channel written into native config |
| --yes | off | Accept defaults instead of prompting |
| --no-install | (installs) | Skip installing SDK dependencies |
| --ios-bundle-id <id> | auto-detected | iOS bundle identifier, when it can't be read from Info.plist |
| --android-app-id <id> | auto-detected | Android application id, when it can't be read from build.gradle |
Expo projects — expo installed plus an app.json with an expo key or
an app.config.{js,mjs,cjs,ts,mts,cts} — take a different path, because
expo prebuild regenerates ios/ and android/ and throws away anything
written there. init registers the config plugin instead:
{ "expo": { "plugins": [["@rollbird/react-native", { "channel": "production" }]] } }and records the channel, the public key, "runtime": "expo" and one
fingerprints.{ios,android} value per platform in rollbird.json — which is
what the plugin reads at prebuild time, locally and on an EAS worker. It also
adds @expo/fingerprint to devDependencies: init and release both read
the app's own copy, so it has to be a declared dependency rather than whichever
one the SDK happens to hoist (a range the project already declares is left
alone). Re-running init updates that one plugin entry rather than adding a
second. When the project is configured by an app.config.js/.ts (code, which
Rollbird won't rewrite), init prints the snippet to paste and says so. Then
run npx expo prebuild (or let EAS Build run it) and rebuild the app before
releasing.
The Expo fingerprint is computed here and only here. init stores it, the
config plugin stamps that stored string into the binary, and release tags the
release with it — one value, so a build and its updates always agree.
Recomputing on both sides does not work: @expo/fingerprint adds a
dir:android (and dir:ios) source as soon as those directories exist, so any
machine that has prebuilt locally computes a value none of its own binaries
carry. Re-run rollbird init after adding or changing native dependencies — it
refreshes the stored value in place — then npx expo prebuild and rebuild.
init refuses (exit 2) when expo-updates is installed: it and Rollbird
both control the update process, so an app carrying both updates
unpredictably or crashes. Remove expo-updates and stop using EAS Update
first.
If creating the project would go over your plan's app limit, init exits
with code 4 and says what to upgrade to. In a terminal (without --yes)
it offers to open checkout instead, waits for the upgrade, and then carries
on with setup — see Plan limits.
rollbird release
Builds, signs, and publishes a new OTA release. For each detected platform:
computes the native fingerprint, builds the bundle, zips it, fetches this
project's signing key and signs both the archive hash and its immutable release
targeting metadata. It refuses to publish if the project has no signing key.
It uploads to the presigned URL
the API returns, then publishes. Attaches git metadata (commit, branch,
author) and a detected CI provider (GITHUB_*, CI_* → GitLab,
BITRISE_*, CIRCLE_*) when available.
| Flag | Default | Description |
| --- | --- | --- |
| --production | off | Publish to the production channel (shorthand for --channel production) |
| --channel <name> | preview | Release channel |
| --rollout <percent> | 100 | Staged rollout percentage, 1–100 |
| --message <text> | none | Release message |
| --force | off | Force the update on-device (shouldForceUpdate) |
| --platform <platform> | both detected | Build only ios or android |
| --target-app-version <version> | none (fingerprint strategy) | Target app version, for the appVersion update strategy |
| --yes | off | Skip the confirmation prompt when publishing to production |
On an Expo project ("runtime": "expo" in rollbird.json) the bundle is
built with npx expo export:embed — the same command Expo's own build phase
runs — with the entry file taken from package.json's main, and both
platforms are built when no native directory exists yet. export:embed emits
plain JavaScript even for a Hermes app, so the bundle is then compiled to
bytecode with the app's own hermesc (from
node_modules/react-native/sdks/hermesc/) and shipped in its place, matching
what a bare release carries; a Hermes app with no compiler installed fails
the release rather than quietly shipping source. The release is tagged with the
fingerprint stored in rollbird.json — the same string the plugin stamped into
the binary — and release refuses (exit 2) if there isn't one, naming
rollbird init.
It also re-checks that stored value, but only where the check means something:
with no ios//android/ on disk, the state init measured in. A difference
there means native-affecting changes landed since, and release says to re-run
rollbird init, prebuild and rebuild. On a prebuilt checkout a recompute
measures something else (@expo/fingerprint hashes the generated directories
too), so release says the fingerprint wasn't re-checked here instead of
crying drift on every single release.
When rollbird.json records a channel and you publish to a different one,
release says so — the channel is baked into the build, so those installs
never ask for that release. It's a note, not a refusal.
After a production publish on a plan with a monthly production-release
limit, release warns when you're down to your last one (1 production
release left this month (resets Oct 1). Upgrade for unlimited: …) and when
you've just used it (That was your last production release this month …).
With --json there's no warning; the success object gains a quota field
({ used, limit, resetsAt, upgradeUrl }, where used includes this
publish) instead. If a limit stops the release, see
Plan limits.
rollbird rollback [number]
Rolls a channel back to the previous active release — instant, free, and
always allowed regardless of quota or plan status. Omit [number] to roll
back the channel's current active release; pass a release number to target a
specific one.
| Flag | Default | Description |
| --- | --- | --- |
| --yes | off | Skip the confirmation prompt |
| --channel <name> | production | Channel to roll back |
rollbird status
Prints the current project, each channel's latest release (number, status,
rollout, published), a production-release quota bar, and storage usage. Reads
rollbird.json in the current directory; no flags beyond the global ones.
Global flags
| Flag | Description |
| --- | --- |
| --json | Emit one JSON object on stdout; human-readable output moves to stderr (see Scripting) |
| --api-url <url> | Override the Rollbird API URL for this invocation (takes precedence over ROLLBIRD_API_URL and the credentials file) |
Authentication
Credentials resolve in this order:
ROLLBIRD_TOKEN(+ optionalROLLBIRD_ORG,ROLLBIRD_API_URL) environment variables.~/.config/rollbird/credentials.json(or$XDG_CONFIG_HOME/rollbird/), written byrollbird login.
Several orgs: release, status, rollback and a re-run init use the
saved login whose org owns the project in rollbird.json, so repos in
different orgs work side by side without logging in again. With one saved
login the CLI uses it directly; with more it asks the API which one can see the
project, latest login first. A new init links into the latest login's org.
CI: set ROLLBIRD_TOKEN as a secret in your CI provider — rollbird
login is unnecessary there. The API resolves the org from the token itself,
so ROLLBIRD_ORG is optional; set it too only if you specifically want the
CLI to send x-rollbird-org (harmless as long as it matches the token's own
org — the API 403s only on a genuine mismatch). For example, in GitHub
Actions:
- run: npx rollbird release --production --yes
env:
ROLLBIRD_TOKEN: ${{ secrets.ROLLBIRD_TOKEN }}
# ROLLBIRD_ORG: ${{ secrets.ROLLBIRD_ORG }} # optional, see aboveThe CLI never prompts when stdout isn't a TTY or CI is set in the
environment — a missing required input exits with code 2 and a one-line
fix instead of hanging on a prompt. Pass --yes to skip confirmation prompts
(release --production, rollback) explicitly rather than relying on
non-interactive detection alone.
Project config
rollbird init writes rollbird.json to the project root — commit it:
{ "projectId": "proj_...", "projectKey": "pk_..." }No secrets live in this file; it only identifies the project so release,
rollback, and status know which one to talk to. Signing keys are
generated and held server-side (see init's "Signing key" step). The CLI
receives the encrypted-at-rest project private key over its authenticated
session for in-memory signing. It never writes that key to project or
credential files.
On an Expo project it carries three more fields, because expo prebuild
regenerates the native config and this file is what survives it:
{
"projectId": "proj_...",
"projectKey": "pk_...",
"channel": "production",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...",
"runtime": "expo"
}The public key is public by design (devices verify signatures with it); the
private half never leaves the server. runtime is absent on bare projects.
A rollbird.json that exists but doesn't parse — bad JSON, an empty
channel, an unknown runtime — is reported as invalid, naming the fields
that failed, instead of being treated as missing.
Scripting (--json)
Every command accepts --json: exactly one JSON object is written to
stdout, and the same human-readable lines you'd otherwise see on stdout are
written to stderr instead — so you can pipe stdout straight into jq without
the human transcript in the way, while still seeing progress if you also
show stderr.
npx rollbird --json release --production --yes | jq -r '.number'A failed command writes an { "error": { ... } } object instead, with
exitCode, message, and, when present, hint, the API's error code, and
its meta (for plan limits: resource, limit, used, plan,
upgradePlan, upgradeUrl, and resetsAt for monthly releases).
Exit codes
| Code | Meaning |
| --- | --- |
| 0 | Success |
| 1 | Release failed (build/bundle error) |
| 2 | Usage or configuration error (missing rollbird.json, bad flag, cancelled prompt) |
| 3 | Not authenticated (run rollbird login, or set ROLLBIRD_TOKEN) |
| 4 | Plan limit reached (message says which limit, and how to upgrade — see Plan limits) |
| 5 | Network or upload failure |
Plan limits
When a release hits a plan limit (production releases per month, storage, apps, or seats), the CLI says what's at stake and how to unblock it, e.g.:
Release #142 is built and uploaded, but it can't go live yet.
Free includes 3 production releases a month — you've used 3. Resets Oct 1.
Publish it now: upgrade to Indie ($7/mo, unlimited production releases)
→ https://app.rollbird.dev/settings/billing?plan=indie&interval=year&reason=releases
Preview releases and rollbacks still work: npx rollbird release- Hit when publishing (after upload): the release stays uploaded. In a
terminal, the CLI asks
Open checkout and publish #142 when it's done?; on yes it opens checkout in your browser, checks every few seconds for the upgrade (up to 15 minutes; Ctrl+C stops waiting), then publishes that same release. - Hit when creating the release (before upload): nothing is uploaded yet. In a terminal you get the same checkout offer, and once you've upgraded the release is created, uploaded, and published. Otherwise, upgrade and re-run the same command.
- No terminal (not a TTY,
CIset, or--json): no prompt, just exit code4, with the upgrade URL in the output (and the limit details inerror.metawith--json). - If there's no bigger plan to upgrade to, the message says what the limit is and doesn't offer checkout.
