Keep every dev build. Launch any one in one click.
@rootnative/devhub is a CLI and a core library for Expo developers. It
keeps every development build on your machine, finds the build that matches
your current code by its native fingerprint, and starts a simulator or an
emulator with that build in one action.
Status: alpha. Each alpha can change the CLI, its JSON output, and the core API. The launch of an app comes in a later version. The CHANGELOG lists what each version has.
Documentation: https://rootnative.github.io/devhub/
Source, design, and roadmap: https://github.com/rootnative/devhub
npm install --global @rootnative/devhub
devhub --helpTo run one command with no install, use npx @rootnative/devhub <command>,
for example npx @rootnative/devhub list. The daily sync below runs
devhub, so it needs the global install.
- Node 20.19 or later.
- eas-cli with a login. DevHub runs
easwhen it is on yourPATH. If it is not, DevHub runsnpx --yes eas-cli, which uses the eas-cli of the project when the project has one. A global install is not necessary. Log in one time withnpx eas-cli loginoreas login. DevHub uses that login, and never reads, asks for, or stores a token.
Run devhub doctor to check each requirement. It gives the fix for each
tool that is missing.
| Command | What it does |
|---|---|
devhub sync |
Downloads each finished EAS dev client build of the project in the current folder that the store does not have. |
devhub sync --all |
Runs the sync for each project folder that devhub sync saw before. |
devhub list |
Shows the stored builds: project, platform, profile, app version, git commit, date, and the EAS expiry date. |
devhub where <id> |
Prints the path of a stored build file. <id> is the EAS build ID, or a unique start of it. |
devhub devices |
Lists the Android emulators and devices that adb sees, and on macOS the booted iOS simulators, with their state. |
devhub install <id> |
Installs a stored build on a ready Android emulator or device, or on a booted iOS simulator (macOS only). |
devhub match |
Calculates the native fingerprint of the project in the current folder, and shows the stored builds with the same EAS fingerprint. A match needs no new build. |
devhub ui |
Starts a local server, and opens the DevHub page in the browser. The page shows the stored builds and the devices, installs a build with one click, and syncs each project. |
devhub whoami |
Shows the EAS login state. A sync needs a login. The exit code is 1 without one. |
devhub doctor |
Checks Node, the store, eas-cli and its login, adb, and on macOS Xcode, and gives the fix for each problem. The exit code is 1 when a check fails. |
Options of devhub sync:
--profile <name>also syncs this build profile. Give it again for each other profile.--dry-runshows what the sync downloads, and downloads nothing.--jsonprints the result as JSON.
devhub list, devhub where, devhub devices, devhub install,
devhub match, devhub ui, devhub whoami, and devhub doctor also
accept --json.
Results go to stdout.
Progress and warnings go to stderr. The exit code is 0 for success, 1 for
a failure, and 2 for a usage error.
An AI agent can read llms.txt in the package. It gives the help of each
command, the type of each --json output, and the core API of the installed
version.
DevHub reads eas.json and syncs each build profile with
"developmentClient": true. It follows extends, and it reads the android
and ios blocks, the same as eas-cli. A profile that is a dev client only on
iOS gives only its iOS builds.
DevHub keeps APKs, iOS simulator builds, and iOS device builds (.ipa). An
.ipa cannot install after its provisioning profile expires, but it stays in
the build history.
A build that EAS deleted before the first sync is "expired before sync". DevHub cannot get it back. A daily sync prevents this.
A dev client is a debug build. Its JavaScript runs in development mode, so it does not show the real speed of your app in production.
devhub devices runs adb devices -l. DevHub uses the first
platform-tools/adb in ANDROID_HOME, ANDROID_SDK_ROOT, or the default
Android SDK folder of your system, and else adb from your PATH.
A device in the state ready can get a build. For a device in another
state, DevHub writes the fix to stderr. For example, an unauthorized
device needs you to accept the USB debugging prompt on it.
A ready emulator shows its AVD name, from adb emu avd name. If the
emulator console does not answer, the emulator shows its model name.
On macOS, devhub devices also runs xcrun simctl list devices --json. It
shows only the booted iOS simulators, with their iOS version. tvOS, watchOS,
and visionOS simulators do not show. If adb or Xcode is missing, DevHub
writes a warning and lists the devices of the other tool. The exit code is
1 only when no tool works.
devhub install <id> installs a stored build on a device that runs now. It
does not start a device, and it does not open the app.
- An Android
.apkinstalls withadb -s <serial> install -r -d <apk>.-rreplaces the installed app, and-dlets you go back to an older build. - An iOS simulator build installs with
xcrun simctl install <udid> <app>, on macOS only. The first install unpacks the archive into theextractedfolder of the store. The next install of the same build uses that folder again.
DevHub checks the build before it looks for a device. It stops, and gives the fix, for these builds:
| Build | Why it cannot install | Fix |
|---|---|---|
Android App Bundle (.aab) |
adb installs only an APK. | Set "android": { "buildType": "apk" } in the build profile in eas.json, and make a new build. |
iOS device build (.ipa) |
A simulator cannot run it. | Set "ios": { "simulator": true } in the build profile in eas.json, and make a new build. |
| Any iOS build on Windows or Linux | iOS simulators run only on macOS. | Install the build on a Mac. |
After it selects the device, DevHub reads the SHA-256 of the stored file
and compares it with the record. The sync and the local store write the
hash of the file they store. When the two differ, the file changed after
that, and DevHub does not install it. The exit code is 1, and the message
gives the fix: delete the build folder, and run devhub sync or
npx expo run:<platform> again.
When adb install fails, DevHub reads the adb output and gives the cause
and the fix:
| adb output | Cause |
|---|---|
INSTALL_FAILED_UPDATE_INCOMPATIBLE |
The installed app has a different signing key. |
INSTALL_FAILED_VERSION_DOWNGRADE |
The installed app is newer, and the device does not allow a downgrade. This occurs when the installed app is a release build. |
INSTALL_FAILED_NO_MATCHING_ABIS |
The build has no native code for the CPU architecture of the device. |
INSTALL_FAILED_OLDER_SDK |
The Android version of the device is older than the minimum Android version of the build. |
INSTALL_FAILED_INSUFFICIENT_STORAGE |
The device does not have sufficient free space. |
INSTALL_FAILED_USER_RESTRICTED, INSTALL_FAILED_ABORTED |
The device did not accept the install. Accept the prompt, or turn on "Install via USB" in the developer options. |
INSTALL_PARSE_FAILED_*, INSTALL_FAILED_INVALID_APK |
The device cannot read the file. The stored file can be damaged. |
Can't find service: package |
The device has not finished its start. |
For the first two, the fix is to uninstall the app. DevHub prints the
adb uninstall command and stops. It never uninstalls an app, because an
uninstall deletes the app data. For a code that is not in the table, DevHub
prints the last lines of the adb output.
DevHub uses the one ready device of the build's platform: an Android
emulator or device for an Android build, and a booted simulator for an iOS
build. When two or more are ready, select one with --device <id>, where
<id> is the adb serial or the simulator UDID. devhub devices shows the
IDs. When no device is ready, DevHub gives the reason for each device.
To load the JavaScript of the project, run npx expo start in the project
folder, and press a for Android or i for iOS. Expo CLI then opens the
installed dev client with the URL of Metro.
DevHub is also an Expo build cache provider. With it, npx expo run:android
and npx expo run:ios store each new build, and install a stored build in
place of a new build when the native code did not change.
-
Add DevHub to the project:
npm install --save-dev @rootnative/devhub
-
Add the provider to the app config (
app.jsonorapp.config.js):{ "expo": { "buildCacheProvider": { "plugin": "@rootnative/devhub/cache-provider" } } }Expo SDK 53 reads the key under
expo.experiments.
The first npx expo run:android builds the app, and DevHub stores the APK.
The next run with the same native code installs the stored APK, and Gradle
does not run. iOS works the same for a simulator. Expo CLI does not use a
build cache provider for an iOS device.
A stored build serves a run when all of these agree:
- The fingerprint of the platform. DevHub calculates it with the
@expo/fingerprintof the project, the same asdevhub match. - The Android variant (
--variant, defaultdebug) or the iOS configuration (--configuration, defaultDebug). - Android: the APK has native code for each ready device. A debug build of
npx expo run:androidholds only the ABI of the device that it was made for. With--device <name>, DevHub checks only that device. With--all-arch, the APK must hold all four ABIs. - iOS: a Mac with the same architecture made the build.
When DevHub is not sure that a stored build fits, Expo CLI builds the app.
DevHub does not use or store a build of a run with --scheme. When DevHub
cannot read or write the store, it gives a warning, and the run continues.
In a managed project, git ignores the android and ios folders, and the
fingerprint does not read them. Expo CLI runs prebuild only when the folder
is missing. So after a change to the app config, npx expo run:* builds
the old native code with the new fingerprint. DevHub does not store such a
build: when app.json or app.config.* is newer than the last prebuild,
it gives a warning. Delete the folder, or run npx expo prebuild --clean,
and then run again.
DevHub serves only the builds that npx expo run:* made, not the builds
that devhub sync downloaded from EAS.
devhub match tells you which stored builds work with the code in the
current folder. A dev client needs a new build only when the native
fingerprint changes. DevHub runs the @expo/fingerprint of the project,
with the same options as eas-cli, and compares the hash of each platform
with the EAS fingerprint of each stored build.
Project: /Users/me/my-app (@expo/fingerprint 0.20.13)
android 3f2a0c9e1b7d4a6f8e2c5b1d9a7f3e6c4b8d2a1f
ID PLATFORM PROFILE VERSION COMMIT CREATED EAS EXPIRY
5d1c9a2e android development 1.4.0 8b1f2c3 2026-10-12 2026-11-11
ios 9c1b7e4d2a6f0c3e8b5d1a7f4e2c9b6d3a8f1e5c
No stored build matches this fingerprint.
--platform <name>limits the match toandroidorios. Give it again for the other platform. The default is both.--jsonprints the result as JSON, with the full record of each match.
The exit code is 1 when no stored build matches, so a script can make a
new build only when it is necessary. The project folder is the nearest
folder with a package.json, the same as for Expo CLI and eas-cli. In a
monorepo, run the command in the app folder. The project needs Expo SDK 52
or later, which ships expo/fingerprint.
When a build does not match, compare the hash with the fingerprint on the
EAS build page. A different @expo/fingerprint version, a change to a
native input, or an env value of the build profile that changes the app
config can give a different hash. DevHub applies no env of a build
profile.
devhub ui starts a server on your machine, prints its URL, and opens the
URL in your browser. Press Ctrl+C to stop the server.
--port <n>sets the port. The default is a free port.--no-openprints the URL, and does not open the browser.--jsonprints the URL and the port as JSON.
The page shows the stored builds of each project and the devices that run now. A card at the top of the sidebar selects the project, or all projects. The sidebar also lists the devices and Setup, and shows the store folder. In a wide window, the builds are a table with the start of the fingerprint of each build. It reads the devices again when you return to the window, and when you click Refresh.
Click Install on a build to install it on the ready device of its platform.
When two or more devices are ready, select the device in the menu. The
button is off when no device of the platform is ready. A build that
devhub install refuses has no button, and shows the reason and the fix.
When an install fails, the page shows the same message as the CLI.
The toolbar shows the EAS login state and the Sync button. Sync is off without a login, with the fix in its tooltip. Click Sync to
run devhub sync --all. The terminal of devhub ui shows
the progress, and the page shows the result of each project when the sync
ends. Only one sync runs at a time: while a sync runs from the terminal or
from a second tab, the page shows that message.
Open Setup in the sidebar, and click Check to run the checks of
devhub doctor.
The page shows the fix below each warning and failure. It runs the checks
only on a click, because the EAS check can use the network.
The server listens on 127.0.0.1 only, never on your network. Each run
makes a new token, and puts it in the URL after #token=. Each API call
must send the token, so a different page in your browser cannot use the
API. Open the URL that devhub ui prints. A URL with localhost does not
work.
The store is one folder for all your projects. It is never inside a project folder.
| System | Folder |
|---|---|
| macOS | ~/Library/Application Support/devhub |
| Windows | %LOCALAPPDATA%\devhub |
| Linux | $XDG_DATA_HOME/devhub, or ~/.local/share/devhub |
Set DEVHUB_HOME to use a different folder, for example on an external
disk. A dev client build can be more than 100 MB.
The extracted folder in the store holds the unpacked iOS simulator builds.
You can delete it at any time. The next install unpacks the build again.
The store can also hold local builds: builds that npx expo run:android
and npx expo run:ios made on your machine. devhub list shows the
profile of a local build as local and its variant, for example
local debug, and no EAS expiry date. Its ID is a UUID that DevHub makes.
devhub where, devhub install, and devhub match accept a local build
the same as an EAS build.
EAS deletes a build after its retention period. Run devhub sync once in
each project folder. Then a daily devhub sync --all keeps the builds of
each project, also the projects that you do not work on now.
A scheduled job does not read your shell profile, so it can have a short
PATH. The job must find node, devhub, and eas or npx. npx is in
the folder of node. Run this command to see their folders, and put each
folder in the PATH of the job:
command -v node devhub eas npxRun crontab -e, and add these two lines. Replace the PATH value with the
folders from command -v.
PATH=/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin
0 9 * * * devhub sync --all >> "$HOME/devhub-sync.log" 2>&1
cron does not run a job that it missed while the computer was asleep or off. On macOS, launchd is a better choice for a laptop.
Save this file as
~/Library/LaunchAgents/io.github.rootnative.devhub.sync.plist. Replace the
path of devhub, the PATH value, and YOU with your values.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>io.github.rootnative.devhub.sync</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/devhub</string>
<string>sync</string>
<string>--all</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/YOU/Library/Logs/devhub-sync.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOU/Library/Logs/devhub-sync.log</string>
</dict>
</plist>Load the job, and run it one time to test it:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.github.rootnative.devhub.sync.plist
launchctl kickstart gui/$(id -u)/io.github.rootnative.devhub.syncIf the computer is asleep at 9:00, launchd runs the job when the computer
wakes. To remove the job, run
launchctl bootout gui/$(id -u)/io.github.rootnative.devhub.sync.
Run these commands in PowerShell. -StartWhenAvailable runs a missed sync
when the computer is on again.
$action = New-ScheduledTaskAction -Execute 'cmd.exe' `
-Argument '/c devhub sync --all >> "%LOCALAPPDATA%\devhub-sync.log" 2>&1'
$trigger = New-ScheduledTaskTrigger -Daily -At 9am
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable
Register-ScheduledTask -TaskName 'DevHub by Root Native sync' `
-Action $action -Trigger $trigger -Settings $settingsTo test the task, run Start-ScheduledTask -TaskName 'DevHub by Root Native sync'.
To remove it, run
Unregister-ScheduledTask -TaskName 'DevHub by Root Native sync'.
Two syncs cannot run at the same time. If a scheduled sync starts while
another sync runs, it stops with a message and exit code 1.
MIT