tapflow streams iOS simulators and Android emulators into a browser, so a whole team can test an app without installing anything. The simulators run on a Mac that hosts the tapflow agent, and that Mac needs the mobile toolchain: Xcode, a simulator runtime, the Android SDK, an emulator, AVDs.
tapflow already gives the people who use it a zero-install experience. We wanted the person who hosts it to get the same one. So tapflow setup brings the whole host environment up in as close to one command as the toolchain allows.
Here are the DX decisions behind it.
doctor diagnoses, setup installs and configures
We split diagnosis from the steps that install and configure.
tapflow doctor is read-only: it checks the prerequisites — Xcode, simctl, a simulator runtime; the SDK, adb, an AVD — and reports. It never changes your machine, so it's safe to run anywhere, anytime. A clean run looks like this:
tapflow doctor
✓ Node v20.11.0
iOS
✓ Xcode 16.2
✓ xcrun simctl
✓ Simulator available (8)
Android
✓ Android SDK: ~/Library/Android/sdk
✓ adb found: ~/Library/Android/sdk/platform-tools/adb
✓ AVD available: tapflow-phone
All checks passed.
When something's missing, each failing line carries the exact fix — ⚠ AVD → No AVD found. Run: tapflow setup android — so doctor always points straight at setup.
tapflow setup is the one command allowed to install and configure. The two mirror each other, so the mutating verb lives in exactly one place. Run setup with no argument and it reads the environment:
if (process.platform === 'darwin') platforms.push('ios')
if (resolveAdb() !== null) platforms.push('android')
macOS implies iOS; an existing adb implies you care about Android. If neither signal is there, it asks instead of guessing.
iOS: installed ≠ usable
Xcode can only come from the App Store, so setup doesn't fake it — it opens the right page and waits.
The part we kept getting wrong was that a freshly installed Xcode isn't a working one. Three steps stand between "the app exists" and "xcodebuild runs": point the active developer directory at Xcode (xcode-select -s), accept the license, and finish first launch. setup runs them for you, after asking, since they need sudo. The check that matters isn't "does Xcode.app exist" — it's whether xcodebuild -version actually runs.
Android: a self-contained SDK
This is the decision we're happiest with.
The obvious path is "install Android Studio." We didn't. The host doesn't need a GUI IDE, and depending on one means fighting whatever SDK location, ANDROID_HOME, and AVDs the user already has. Instead setup builds a self-contained SDK under one path we own:
sdkmanager --sdk_root=~/Library/Android/sdk \
"cmdline-tools;latest" "platform-tools" "emulator" \
"system-images;android-35;google_apis;arm64-v8a"
After that, every Android binary tapflow touches comes from inside that directory. A couple of details that make it reliable:
-
sdkmanagerneeds a JDK or it won't run, so a Temurin check happens first. - The system image is
google_apis, not the Play Store one, which is unstable the way we drive it.
The one thing that has to outlive the process is ANDROID_HOME on your PATH. setup writes it into your shell rc inside a marker block, and only if it isn't already there — so re-running never duplicates it:
# >>> tapflow android sdk >>>
export ANDROID_HOME="$HOME/Library/Android/sdk"
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
# <<< tapflow android sdk <<<
The catch it can't remove: the variable isn't in your current shell, so setup tells you to open a new terminal before doctor.
setup prepares; the relay boots
For both platforms, setup stops at "a bootable device exists." It never boots a simulator or emulator. That's the relay's job — it boots the right device on demand when a teammate joins a QA session. Two components owning device lifecycle would just race each other.
One rail under all of it
Every step that changes the machine asks first, and only auto-runs in an interactive terminal. Run setup in CI and instead of curling an install script as root, it prints guidance and exits clean. Nothing in tapflow deletes your data — the only teardown command is reset, which shuts down running simulators.
Honest limitations
- Xcode is still a manual App Store download. There's no API for it;
setupautomates everything around it. - The first run usually needs a new shell for the Android
PATHto take effect. - The agent host is a Mac, since that's the only place iOS simulators run. (The relay itself runs on Linux.)
- Still v0.x, so the steps will keep moving as the toolchain shifts.
Takeaway
The downloads were never the hard part. The DX lives in the glue around them — Xcode activation, the PATH that needs a fresh shell, knowing to stop at "bootable" and let the relay boot the rest. Automating a dev environment means automating everything that isn't the install.
Try it
tapflow is MIT licensed.
npm install -g tapflow
tapflow doctor # what's missing?
tapflow setup # set it up
tapflow start
- 🔗 GitHub: https://github.com/jo-duchan/tapflow
- 📖 Docs: https://www.tapflow.dev
Top comments (5)
One command is powerful only if it is idempotent. The first run matters, but the fifth run after a half-failed setup is where trust is won or lost.
For developer machines, I like setup scripts that can explain state: what is already installed, what changed, what needs manual approval, and what failed without leaving the machine in a weird middle state.
Totally agree. The first install is usually the easy part. Re-running after something failed halfway is the real test.
In tapflow, every setup step checks the current state first. Homebrew, JDK, Android SDK, AVDs, etc. If it's already there, the step becomes a no-op. The shell config update is also wrapped in a marker block, so re-running won't duplicate PATH entries.
So in most cases, another run just fills in what's missing instead of starting over.
One gap is state reporting. Right now, "already installed" and "installed during this run" both show up as the same success state.
There's also a partial-state issue with the Android SDK check. It only verifies that
sdkmanagerandadbexist. If the system image download fails halfway, the next run can still report the SDK as ready and then fail later during AVD creation.There's no rollback for that case yet. For now it relies on idempotent re-runs, which works most of the time but not there.
Better state reporting is probably the next thing I'll improve.
Exactly. The retry path is where setup tools either become trustworthy or painful. I like the marker-block/no-op approach; the next useful layer is probably a small state report that separates "found", "created", and "repaired" so partial SDK state is visible before AVD creation.
That's exactly the distinction I was missing.
Right now my model is too simple — a step result is only ok/not-ok, so "it was already installed" and "I installed it just now" both end up as the same green check.
Having each step report whether it found, created, or repaired something would make that much clearer. It would also help with partial-state cases. For example, the SDK step currently only checks for sdkmanager and adb, but not whether the required system image is actually installed.
I opened an issue so I don't forget about it: issue link
I also added credit for the suggestion. Thanks for pointing this out.
That found/created/repaired split would make the output much more trustworthy. It turns green from a cosmetic status into a small audit log of what actually changed.