CLI reference
otapush is the command-line client for an Otapush server: it binds a project to an app, publishes updates, and moves channels forward or back. Install and check the built-in help:
npm install -g otapush # or: bun add -g otapush
otapush --help
The command set is deliberately small: login, whoami, init, publish, list, rollback, promote, keys. Everything that has no CLI — creating apps and channels, stats charts, per-platform rollback buttons — lives in the web portal; the portal side of the workflow is in the Quickstart.
Two credential stores
~/.otapushrc.json— global, written byotapush login: the server URL and a portal JWT token. The file is created with mode0600. Every command reads it../otapush.config.json— per project, written byotapush initandotapush keys create:server,appId,slug,runtimeVersion,projectType(expoorbare) and, after a key is created,apiKey. Every project command (publish,list,rollback,promote,keys) requires it and fails without one.- The
OTAPUSH_API_KEYenvironment variable overridesapiKeyfrom the project config. There are no other environment overrides, and there is no--jsonoutput mode.
--server <url> exists on login and init only; the remaining commands take the server from otapush.config.json.
Who authenticates how
login,whoami,keys— the portal Bearer token from~/.otapushrc.json. Not logged in, they stop with "Runotapush login --server <url>first".publish— an API key only (the project config'sapiKeyorOTAPUSH_API_KEY). A login session is not enough: the publish endpoint accepts onlyx-api-key.list,rollback,promote— either: an API key when one is available, otherwise the login token.
API keys are scoped to a single app — the server verifies the key belongs to the app in the request path and answers 403 otherwise.
otapush login — store portal credentials
otapush login --server https://ota.example.com
Interactive: asks for the server URL (default http://localhost:3000 unless --server is given), then email and password, and saves the JWT to ~/.otapushrc.json. Run it once per machine, before init. There is no logout command — delete ~/.otapushrc.json to forget the credentials.
otapush whoami — check what you are logged in as
otapush whoami
Prints the server URL and the authenticated user (name, email, id). Requires a login session. Useful after switching servers or accounts to see what your next command would act as.
otapush init — bind a project to an app
otapush init [--server <url>]
Requires a login session. There are no --app or --force flags: init lists the apps on your account and asks which one to bind (or create a new one by name and slug); answering the prompt again on a re-run is the way to re-bind, and all patching is idempotent — existing values are replaced, never duplicated.
What it writes for a managed Expo project (app.json present):
expo.updates—url,enabled,codeSigningCertificate,codeSigningMetadata. An existingupdatesblock is merged, not replaced.expo.runtimeVersion— only ifapp.jsondoes not have one already; a value you set by hand is left alone.certs/certificate.pem— the app's public certificate, re-fetched from the server on every run.otapush.config.json— the project binding (runtimeVersionhere always comes from the server's app record, which is whatpublishuses by default).
If your config is app.config.js/app.config.ts instead of app.json, init does not patch it — it prints the exact JSON to paste into the expo object.
What it writes for a bare React Native project (no app.json, react-native dependency): meta-data entries in android/app/src/main/AndroidManifest.xml, the EXUpdates* keys in ios/<ProjectName>/Expo.plist, the same certificate file and the same config with projectType: "bare" — all of it documented in Bare React Native.
init changes nothing on the server; the app, its channels and its keypair already exist. The failure modes — wrong directory, missing files, a project type that can't be detected — are listed in the "what went wrong" sections of Expo setup and Bare React Native.
otapush publish — export and upload an update
otapush publish [--channel dev] [--platform ios|android|all] [--message <msg>] [--runtime-version <v>]
The workhorse. Defaults: --channel dev, --platform all (which exports and publishes each platform in turn). --platform accepts a single value — to publish one platform, name it; there are no short flags and no repeatable -p.
It requires an API key and refuses to run with only a login session. Then, per platform:
- Export. Managed projects:
npx expo export --platform <p> --output-dir dist-ota, with the bundle and assets resolved from the generatedmetadata.json. Bare projects:npx react-native bundle --dev false --entry-file index.js --bundle-output … --assets-dest …(the entry fileindex.js/index.tsis detected automatically). - Upload as multipart form data: the bundle, every asset, plus
channel,platform,runtimeVersion,messageand the current git commit — read automatically fromgit rev-parse HEADwhen the project is a git repository (there is no flag to override it; outside a repo the field is simply omitted).
What changes on the server: a new update record is created; the bundle is stored under bundles/<appId>/<updateId>/bundle.js; assets are stored content-addressed as assets/<sha256><ext>, and an asset the server already has — from any earlier update of this app — is not written again; finally the channel's pointer for that platform moves to the new update and any rollback flag on the channel is cleared.
Errors you can hit: "No API key" (create one with keys create or set OTAPUSH_API_KEY), channel '<name>' not found (channels are created in the portal — three exist by default), and a non-zero exit from the export tool, whose code is reported as-is.
otapush list — see updates and what is live
otapush list [--channel <name>] [--platform ios|android]
Prints a table of the app's updates — id, channel, platform, runtime version, message, creation time — with * marking the update each channel currently points at. The filters combine. Authenticates with an API key or a login session.
otapush rollback --channel <name> — step the channel back
otapush rollback --channel prod
Moves the channel back to the previous update for that update's platform; devices recover on their next check. Rolling back the channel's first update on a platform is a special case handled server-side: the channel enters the rollBackToEmbedded state and clients revert to the bundle compiled into the binary — Protocol explains the directive.
Two things to know before you reach for it in a hurry:
- Which platform it hits follows the CLI's single-pointer view of the channel: iOS first. If the channel has current updates for both platforms, the CLI rolls back the iOS one; the Android pointer is reached only when iOS has none. To roll back exactly one platform, use the portal's Updates tab.
- The server refuses with
400if the update is not the channel's current one — rolling back a non-current update would silently skip versions nobody inspected.
otapush promote --from <ch> --to <ch> — copy the current update to another channel
otapush promote --from staging --to prod
Promotes the --from channel's current update into the --to channel, for that update's platform, and clears the target channel's rollback flag. This is the standard staging → prod move: devices get the exact bytes you tested, not a rebuild. --from and --to must differ, and both channels must exist. The CLI prints a reminder that channels are per-platform — repeat the command for the other platform, or use the portal.
otapush keys create|list|revoke — manage API keys
otapush keys create ci # prints the key once, saves it to otapush.config.json
otapush keys list
otapush keys revoke <keyId>
create <name>requires both a login session and a project config — the key belongs to the bound app. The full key is displayed exactly once and stored server-side only as a hash. When run inside a project, it is also written tootapush.config.jsonasapiKey.listshows id, name and creation date; the key material is never shown again.revoke <keyId>deletes the key on the server. The CLI warns ifotapush.config.jsonstill holds the revoked key — remove it or create a new one.
Using the CLI in CI
login is interactive, so CI uses the API key only. The setup is three lines long:
- Locally, once:
otapush init(writesotapush.config.json), thenotapush keys create ci. - Commit
otapush.config.json— it holds the server URL, app id and slug, no secrets — and put the key into your CI secret store asOTAPUSH_API_KEY. (If the key was saved into the config bykeys create, remove theapiKeyfield before committing, or keep the secret only in the environment variable.) - In CI,
publish,list,rollbackandpromoteall work with the environment variable alone.
GitHub Actions example:
- run: otapush publish --channel prod --platform all --message "${{ github.event.head_commit.message }}"
env:
OTAPUSH_API_KEY: ${{ secrets.OTAPUSH_API_KEY }}
Remember that publish runs your project's export tool (npx expo export or npx react-native bundle): the CI job needs the project's usual JavaScript toolchain — installed dependencies and all — not just the otapush binary.
Exit codes
0 — success. 1 — any failure: usage errors, HTTP errors from the server (including 401/403), a failed export, an unreachable server. There are no other exit codes; treat non-zero as "it failed" and read stderr for the reason — the message names the command, the status and, where possible, the server's own error text.
Questions and answers
Why does publish say "No API key"?
Publishing is authorized by an app-scoped API key, not by the otapush login session. The CLI looks for apiKey in otapush.config.json, then for OTAPUSH_API_KEY in the environment. Fix: otapush keys create <name> inside the project (the key is saved into the config automatically), or export the variable.
Can I run CI without ever calling otapush login?
Almost. publish, list, rollback and promote work with an API key alone, and keys create can be run once on a developer machine. What genuinely needs an interactive login is init (binding a project) and key management itself — both are one-time setup.
Which channel does a device actually check?
The one in its manifest URL. otapush init writes the plain URL without a channel parameter, and the server serves prod when none is requested — so a default build checks prod, and publishing to dev (the publish default!) is invisible to it. Non-prod builds need the URL to carry ?channel=<name>. See the channels question in the Quickstart.
How do I roll back only the Android update?
Not with the CLI while the channel also has a current iOS update — the CLI's view of "the channel's update" is iOS-first, so otapush rollback --channel <name> targets iOS. Use the portal's Updates tab, which rolls back per platform.
What does publish do with assets that were already uploaded?
Skips writing them. Assets are stored by SHA-256, so re-publishing an unchanged image creates no second copy — the new update simply references the stored object. The bundle, unlike assets, is always stored per update.
Where do the --message and the git commit end up?
In the portal's update list, and inside the served manifest's extra object — readable from the app as Updates.manifest.extra. See "A real manifest response" in the Protocol.