Better update
Drive the better-update CLI (@better-update/cli, the `better-update` command) to ship OTA JS/asset updates, run local native iOS/Android builds, manage an end-to-end-encrypted signing-credential vault, set server-side env vars, and route releases through channels/branches with staged rollouts and instant rollback — for Expo, React Native, Kotlin Multiplatform, and native apps (any project type via custom build commands). Use this whenever you are working in a project that uses better-update (an `@better-update/cli` dependency, a `better-update …` command, `expo.extra.betterUpdate.projectId` in app.json, or a better-update manifest URL), OR when the user asks to publish / ship an OTA update, stage a rollout or roll back a release, cut a release candidate, promote an update between channels, build an IPA/APK/AAB locally, manage keystores / distribution certs / provisioning profiles / APNs push keys, sign or notarize a macOS app with a Developer ID certificate, or configure per-environment secrets — even if they never say the words "better-update". Covers every command, every flag, and the publish → branch → channel → device routing model.From its SKILL.md
npx -y skills add aislopware/better-update --skill better-updateAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 6 stars6 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
17.9 KB, as published. Nobody here has run it
better-update CLI
better-update is a self-hosted mobile release platform for Expo, React Native, Kotlin Multiplatform,
and native apps — any project type, including ones driven by a custom build command — running on
Cloudflare. It does OTA JS updates (Expo Updates protocol-compatible), local native builds (EAS
Build-compatible), an end-to-end-encrypted credential vault, server-side env vars, store
submission, and macOS Developer ID signing + notarization (macos sign / macos notarize) — all
driven from one CLI, better-update. This skill is how you operate that CLI on a user's behalf.
The one mental model that explains everything
publish ──► branch ◄── channel ──► device
- You publish an update group (a JS bundle + its assets) to a branch. Branches are server-side streams of updates; they need not match git branches.
- A channel points at exactly one branch at a time. The channel a device reads is baked in at build time and cannot change without a new native build.
- A device on channel
Xfetches the latest compatible update from whatever branchXcurrently points at — if its installed binary's runtime version matches the update's.
This indirection is the whole point: you ship a release candidate to a branch, vet it on a staging
channel, then repoint production at that branch with one command — no rebuild. Rollback is the
same move in reverse.
Two facts that prevent most mistakes:
- Channel ↔ branch is a pointer you move (
channels update <id> --branch <name>), not a recompile. There is nochannels pointcommand. - A published update only reaches a device whose installed build advertises a matching runtime
version. Publish to a runtime no installed build matches → zero devices get it. Verify with
better-update builds compatibility-matrix.
Setup (do this before anything else)
bun add -g @better-update/cli # or: bunx @better-update/cli <cmd> (no install)
better-update login # browser OAuth; --api-key for headless paste
better-update init # from the Expo project root: links app.json → a project
login writes ~/.better-update/auth.json (mode 0600). init reads app.json, finds/creates a
project by expo.slug, and writes the project id into expo.extra.betterUpdate.projectId — every
other command reads it from there. Default server is the one baked into the CLI build (its operator's instance); override with
BETTER_UPDATE_URL / BETTER_UPDATE_WEB_URL or ~/.better-update/config.json. Auth can also come
from BETTER_UPDATE_ROBOT (a robot account, for CI). Full detail: references/getting-started.md.
To know whether a project already uses better-update, look for @better-update/cli in
package.json, expo.extra.betterUpdate.projectId in app.json, or a better-update
manifest URL in expo.updates.url.
Command map
better-update
├── login / logout / whoami Auth + identity (token at ~/.better-update/auth.json, mode 0600)
├── org list · switch — the session's active organization (all commands scope to it)
├── init / status / doctor Link project · status · diagnostics
├── open / autocomplete Open the dashboard · print a shell completion script
├── projects list · create · get · rename · archive · unarchive · delete
├── branches list · view · create · rename · delete
├── update publish · list · view · edit · promote · republish · rollback ·
│ revert · rollout(set/complete/revert) · revert-rollout ·
│ roll-back-to-embedded · embedded:upload · configure · insights · delete
├── channels list · view · create · update(repoint) · pause · resume · delete · insights · rollout
├── build [+ configure] Build the app locally and (by default) upload the artifact
├── builds list · get · download · run · install-link · compatibility-matrix · upload · resign · delete
├── credentials Signing vault + E2E encryption (identity/access/device/unlock/lock);
│ account/env-vault subcommands (or web self-enroll + grant) for browser env editing;
│ certificate/bundle-id/profile/capability list+enable (read-only ASC inventory, CI-safe)
├── env Project env vars: list/get/set/update/delete/history/rollback/import/export/pull/push/exec
├── environments Org environment definitions: list/create/rename/delete
├── fingerprint generate · compare (runtime-compatibility hashes)
├── analytics adoption · updates · downloads · channels · platforms
├── audit-logs list (every mutation, with actor + timestamp)
├── apple login · logout · whoami · accounts (multi-account switch); builds · users (CI-safe); asc-key · sandbox (Apple ID login; `sandbox list` prefers an ASC key)
├── submit Submit a build to App Store Connect / Google Play
├── testflight group · tester · review · build · feedback — full TestFlight beta lifecycle (CI-safe)
├── app-store version · submit/cancel/status/release/reject · rollout · review-detail · info · categories · age-rating · privacy · apps (list + create) · pricing · availability (show + set) · territories · config (pull/push) (CI-safe; apps create needs Apple ID login)
├── reviews list · reply — App Store customer reviews (CI-safe)
├── metadata media (list/sync) · screenshots (upload/clear) · previews (upload) — store media (CI-safe)
├── app-review list · view · rejections · reply — Apple App Review / Resolution Center (Apple ID login, NOT CI-safe)
├── devices Register Apple device UDIDs for ad-hoc/development provisioning
└── webhooks update.published / build.completed subscriptions
Core workflows (the 90% cases)
Ship a JS/asset update — you do NOT run expo export first; publish does it for you:
better-update update publish --branch main --message "Fix login crash on iOS 17"
Stage it to a slice, then ramp (default-on safety net for risky changes):
better-update update publish --branch production --rollout-percentage 10 --message "Cache migration"
better-update update rollout set <updateId> 50
better-update update rollout complete <updateId> # → 100%
better-update update rollout revert <updateId> # → 0%, stops the spread
Cut a release candidate, vet, promote — no re-export, no second upload:
better-update update publish --branch staging --message "v1.4 candidate"
better-update update list --branch staging --limit 1 # grab its id
better-update update promote <updateId> --channel production # or repoint the channel:
better-update channels update <prodChannelId> --branch staging
Roll back production now (devices fall back to the embedded bundle):
better-update update rollback --branch production --message "Revert e7f3 — login crash"
Build a new native binary (needed whenever native code / runtime version changes):
better-update build --platform ios --profile production
better-update builds compatibility-matrix # confirm channels are covered before you publish OTA
Per-environment server-side env vars, injected into expo export at publish time:
better-update env set EXPO_PUBLIC_API_URL=https://api.example.com
better-update env set STRIPE_KEY=sk_live_xxx --visibility sensitive
better-update update publish --branch production --environment production
Submit a build to the stores (App Store Connect / Google Play), from the CLI:
better-update submit --platform ios --latest --what-to-test "Fixed the login crash and refreshed onboarding"
better-update build --platform android --auto-submit # or build + submit in one step
Gotchas worth memorizing
- No
channels point. Repoint withchannels update <id> --branch <name>. - Runtime version gates delivery. Match builds and updates; use
fingerprint+builds compatibility-matrixto verify before publishing. - The channel is build-time. Set it in the build profile /
--release-channel; it cannot be changed OTA. You move branches under channels, never recompile to change which update serves. --commit-timefor rollback must be canonical ISO 8601 with milliseconds and a trailingZ(2026-05-06T14:00:00.000Z). The on-deviceexpo-updatesclient only parsesYYYY-MM-DDTHH:mm:ss.SSSZ; numeric offsets / missing millis are rejected.- Env-var visibility is
plaintextorsensitive— there is NOsecrettier. Passing--visibility secretis rejected by the CLI.sensitivevalues are masked (******) onenv getunless--include-sensitive, and readable in the console only by owners/admins; they are still returned byenv export/env pull. Visibility gates who on the team can read — it does not harden a value against device extraction (anything in a JS bundle is extractable; use a backend). env get <key>andenv delete <key>both take the KEY name (not an ID).env deletewith no--environmentdeletes the key across every environment.env pullwrites.env.localby default, not stdout — use--stdoutto source into a shell (eval "$(better-update env pull --stdout)").update rolloutis per-update %;channels rolloutis a branch-level traffic split. They compose — you can ramp a channel onto a new branch while updates inside it have their own %.- better-update DOES submit to stores from the CLI.
better-update submit --platform ios|androiduploads to App Store Connect (via Apple's Build Upload API when an ASC key is the upload auth — live progress bar — withxcrun altoolas the app-specific-password path and automatic fallback) or Google Play;build --auto-submitchains build → submit. (It does not poll store review — only the upload/submission.) --what-to-testhas a length floor Apple won't document.submitrejects empty or >4000-byte text before upload, but Apple also rejects short strings ("too short" — e.g.Fix, and evenBug fixesin some reports) with no published minimum. Write a full sentence. If it trips after upload, fix it without re-uploading:testflight build whats-new --latest --whats-new "<longer text>".submitis idempotent — just re-run it after a metadata failure. It checks App Store Connect for the IPA's build number before uploading (and Apple 409s the Build Upload API's reserve step for a duplicate — before any bytes upload); if the binary is already there it skips the upload and only re-applies the TestFlight config (so the "already been used" duplicate-build error can't strand you). When the upload succeeds but config fails, the submission is still recorded as metadata-incomplete (dashboard shows amber "Metadata pending"; a finished one reads a plain "Complete"); the re-run that completes config updates that same row.- App Store Connect operations run from the CLI, headless.
testflight …(group / tester / review / build / feedback),app-store …(version / submit / cancel / status / release / reject / rollout / review-detail / info / categories / age-rating / privacy / apps / pricing / availability),apple builds/apple users,reviews …,metadata …(store media), and thecredentialsASC inventory (certificate / bundle-id / profile / capability) all drive App Store Connect with a stored ASC API key — no browser, CI-safe.testflight group createis the fix for asubmit iosthat fails withTESTFLIGHT_GROUP_NOT_FOUND.apple builds compliance --no-uses-encryptionclears a build stranded inMISSING_EXPORT_COMPLIANCE.app-store age-rating set/privacy setare authored from a JSON document (--from), not a flag matrix.metadata media syncdeclaratively pushes ascreenshots/<locale>/<device>/*.pngtree (with--dry-run/--prune).apple usersneeds an Admin-role key. Seereferences/cli.md. - A few App Store Connect ops are cookie-only (Apple ID login + 2FA, NOT CI-safe).
app-review …(Resolution Center: read App Review threads / rejection guideline codes / reply — text only),apple asc-key list+credentials revoke asc-key(the upstream ASC-key lifecycle),app-store apps create,credentials bundle-id create --app-clip, andapple sandbox create/delete(IAP testers) all hit Apple's Iris API, which a JWT key can't reach — they log in viaapple loginand fail with a clear error under--non-interactive/ CI. (apple sandbox listis token-first: it prefers the public ASC API when an ASC key is configured via--asc-api-key-idor the submit profile, falling back to Apple ID login.) A rejectedapp-store submitis closed out withapp-review list→app-review rejections --thread <id>→app-review reply.
Reference index — read the file that matches the task
The depth lives in references/. Pull the one you need; don't load them all.
| Read this | When you're… |
|---|---|
references/getting-started.md | installing, logging in, pointing at a self-hosted server, linking a project, or using whoami/doctor/open |
references/publishing.md | publishing updates, every update publish flag, runtime-version policies, promote/republish/edit, the audit log |
references/channels-and-branches.md | new-branch-vs-channel, repointing channels, view/insights, the cut→vet→promote pattern, channel rollouts |
references/rollouts-and-rollbacks.md | staging a % rollout, the three "revert" verbs, whole-branch rollback, the symptom→action decision table |
references/native-builds.md | running build (+configure), managing builds (download/run/resign), fingerprint, store submit |
references/credentials.md | the signing + E2E credential vault: certs/profiles/keystores/APNs, identity/access/device, unlock/lock, project-scoped robot accounts, credential→project bindings; account/env-vault for browser env-vault access + its troubleshooting |
references/environments.md | env vars (set/get/push/pull/export, visibility), history/rollback, the org environments command |
references/access-control.md | roles (org owner/admin/member + project maintainer/developer/reporter), project-scoped robots, credential→project bindings, protected environments & credentials, Apple device registration, webhooks |
references/cli.md | you need the exhaustive command/flag table for ANY command (every group), or exit codes for CI branching |
When a command's exact flags or exit-code semantics matter (especially in CI scripts), confirm
against references/cli.md — it mirrors apps/cli/src/commands and is the source of truth.