Claude Code skill
expo-local-build
Send a locally-built Expo IPA or APK to my phone via the tunnel.
Install all skills in one command:
claude mcp add-plugin johnkueh/claude-skillsWhy it exists
EAS cloud builds take ~15 minutes and cost a credit. Testing a one-line change on a physical iPhone shouldn't need either. This skill runs `eas build --local` to produce an IPA on your Mac, then serves it from a per-project install page over the Cloudflare tunnel. Tap the link on the phone and the ad-hoc install runs.
In practice
Build and serve
Input
pnpm ios:local
Output
Builds IPA locally (~3 min), publishes install page at <project>.jkyf.dev/install with the manifest plist and signed IPA.
skills/expo-local-build/SKILL.mdRaw
---
name: expo-local-build
description: Serve locally-built Expo IPAs/APKs from this Mac to a phone over the Cloudflare tunnel. The skill owns the install-page + tunnel ingress wiring; each project owns its own `eas build --local` invocation via package.json `:local` scripts. Triggers on "serve the build", "install page", "send build to phone", "deliver expo build", "expo install URL", "publish IPA", "ad-hoc install page".
---
# expo-local-build
This skill **does not build**. Each project owns its build via `pnpm <slot>:local` scripts that wrap `eas build --local`. This skill takes the output directory and publishes it as an install page over the Cloudflare tunnel, reachable from anywhere.
The split:
| | Lives in | Owns |
|---|---|---|
| **Build** | each project's `app/package.json` + `app/scripts/local-build.sh` | `eas build --local` invocation, macOS PATH preamble, slot naming |
| **Serve** | this skill's `deliver.sh` + `install-server.mjs` | install page, tunnel ingress, port allocation, launchd persistence |
## Usage
```sh
SKILL=~/.claude/plugins/marketplaces/johnkueh-skills/skills/expo-local-build/scripts
bash $SKILL/deliver.sh ~/Projects/myapp/app/build-output
# -> https://myapp-install.example.dev/ (open on phone, tap Install)
```
That's it. No flags needed for the common case. The install server reads `build-output/` directly and re-scans on every request, so rebuilding overwrites in place and the page reflects the new build immediately.
### Flags
| Flag | What it does |
|---|---|
| `--label NAME` | Override the project label (default: grandparent dir name of the build-output path, e.g. `my.app` → `my-app`). |
| `--persist` | Run the install server under launchd (`~/Library/LaunchAgents/dev.jkyf.expo-localbuild.<label>.plist`, `KeepAlive`) so it survives logout/reboot. Default is an ad-hoc background process that dies with the shell. |
| `--unpersist` | Remove that LaunchAgent. Standalone: `deliver.sh --unpersist --label <name>` (no dir needed). |
Env: `TUNNEL_TLD` (default `example.dev`).
### What the install page shows
The page lists exactly the files in `build-output/` matching `<profile>-<platform>.<ext>`:
| Slot | Filename | Card |
|---|---|---|
| Dev iOS | `dev-ios.ipa` | ► Install (iOS OTA manifest) |
| Preview iOS | `preview-ios.ipa` | ► Install |
| Prod iOS | `prod-ios.ipa` | ► Install |
| Dev Android | `dev-android.apk` | ↓ Download APK |
| Prod Android | `prod-android.apk` | ↓ Download APK |
Order: prod → preview → dev, iOS before Android. Any file that doesn't match the pattern is ignored — no timestamped builds, no symlinks, no historical noise. Rebuilding a slot overwrites in place.
If you want a build older than the latest, rebuild from a git tag.
## How the tunnel ingress works
`deliver.sh` picks one of two modes automatically:
- **portless/pubproxy present** (something listening on `127.0.0.1:1354`): `install-server.mjs` registers `<label>-install.localhost` in `~/.portless/routes.json` on startup, and the tunnel's wildcard rule (`*.<TUNNEL_TLD>` → pubproxy `:1354`) forwards to it. URL: `https://<label>-install.<TUNNEL_TLD>/`. This is the full [cloudflare-tunnel-portless](../cloudflare-tunnel-portless/SKILL.md) setup.
- **no pubproxy** (e.g. the Mac mini, hand-curated per-host ingress): `deliver.sh` wires a dedicated cloudflared ingress rule straight to the install server's port — finds the running `cloudflared tunnel run <name>`, runs `cloudflared tunnel route dns <name> <label>-localbuild.<TUNNEL_TLD>`, upserts the hostname/service block into `config.yml` (inside a managed `# BEGIN expo-local-build` block), and `SIGHUP`s cloudflared. URL: `https://<label>-localbuild.<TUNNEL_TLD>/`. The `wire-ingress.mjs` helper does the YAML surgery and backs up `config.yml` to `config.yml.bak` once.
Either way the URL only works while the Mac is awake **and** `cloudflared` is running. If it 502s, the tunnel/Mac is down — start `cloudflared tunnel run <name>` (or see the cloudflare-tunnel-portless skill's `doctor.sh`). If `deliver.sh` can't find a running cloudflared it still starts the local server and prints `http://127.0.0.1:<port>/`.
State per project lives in `~/.expo-local-build/<label>/`: `server.pid`, `server.log`, and (under `--persist`) `server.out.log` / `server.err.log`. The IPAs/APKs are NOT copied here — the server reads the project's `build-output/` directly.
## Adding local builds to a new Expo project
Three pieces. Once they're in, `pnpm <slot>:local` builds and `deliver.sh` serves.
### 1. `app/scripts/local-build.sh` — env preamble wrapper
```bash
#!/usr/bin/env bash
# Wrapper for `eas build --local` that sets up the macOS PATH it needs:
# - /opt/homebrew/bin first (so real node beats bun's node shim)
# - Homebrew Ruby + its gem bin (where fastlane lives)
# Also unsets EXPO_TOKEN (Viewer-only robot token breaks credential resolution).
set -euo pipefail
export PATH="/opt/homebrew/bin:$PATH"
RUBY_BIN="/opt/homebrew/opt/ruby/bin"
if [ -x "$RUBY_BIN/gem" ]; then
RUBY_GEMS_BIN="$("$RUBY_BIN/gem" env gemdir 2>/dev/null)/bin"
export PATH="$RUBY_BIN:$RUBY_GEMS_BIN:$PATH"
fi
unset EXPO_TOKEN
exec eas build --local --non-interactive "$@"
```
`chmod +x` it.
### 2. `app/package.json` — one script per slot
```jsonc
"build:dev:local": "scripts/local-build.sh --profile development --platform ios --output build-output/dev-ios.ipa",
"build:dev:android:local": "scripts/local-build.sh --profile development --platform android --output build-output/dev-android.apk",
"build:preview:local": "scripts/local-build.sh --profile preview --platform ios --output build-output/preview-ios.ipa",
"build:prod:local": "scripts/local-build.sh --profile production --platform ios --output build-output/prod-ios.ipa",
"build:android:prod:local": "scripts/local-build.sh --profile production --platform android --output build-output/prod-android.apk"
```
If your project sets `EXPO_APPLE_TEAM_ID` on the cloud scripts, prefix the corresponding `:local` ones too — credentials resolution needs it.
### 3. `app/.gitignore`
```
build-output/
```
### One-time machine setup (per Mac, not per project)
1. **Apple WWDR G3 cert** — download `AppleWWDRCAG3.cer` from <https://www.apple.com/certificateauthority/> and import into the **login** keychain (double-click, or `security import AppleWWDRCAG3.cer -k ~/Library/Keychains/login.keychain-db`). Required for iOS local signing — without it you get `errSecInternalComponent`.
2. **Homebrew Ruby** — `brew install ruby`. The wrapper script picks up the gem bin (where fastlane lives) automatically.
3. **EAS account** — `eas login` once. Don't set `EXPO_TOKEN` in your shell rc; the wrapper unsets it because the CI robot token is Viewer-only and breaks credential resolution.
4. **Apple Developer membership** — paid membership active under the team ID referenced in `eas.json`.
5. **Cloudflare tunnel** — see [cloudflare-tunnel-portless](../cloudflare-tunnel-portless/SKILL.md). The tunnel must be running for `deliver.sh` to publish a public URL.
### One-time project setup (per Expo project)
`eas init` so `app.config.ts` (or `app.json`) has an `extra.eas.projectId`. Without it, `eas build --local --non-interactive` fails with *"EAS project not configured."*
Register the device once: `eas device:create`. After that, every `development` build picks up the device automatically; `preview` / `production` ad-hoc builds need a rebuild after adding new devices.
## What this skill is NOT for
- **Shipping a real release.** App Store `.ipa` and Play `.aab` can't be sideloaded — they reject. Use the project's own `pnpm release` dispatcher.
- **Building in the cloud.** That's `eas build --profile <p> --platform <p>` (no `--local`) and lives in each project's non-`:local` `build:*` scripts.
- **Replacing the dev-client / Metro flow.** For day-to-day work, run `pnpm dev` and use the registered dev client. This skill is for handing a build to a phone — a release-config tester install, or a teammate's device, or "I'm out and need a working build now."
## Stop serving
Ad-hoc (default): `kill $(cat ~/.expo-local-build/<label>/server.pid)`
LaunchAgent (`--persist`): `deliver.sh --unpersist --label <label>`