← Plugin catalog
Developer Tools
Limrun
Limrun, Inc. v1.1.0
Publisher description
From the marketplace listing
Limrun gives your agent iOS simulators and Android emulators in the cloud. Ask for a device and it is ready in seconds, with your app pre-installed if you want.
Language: English · Automatically detected from descriptions.
Files & skills
File archives
Plugin package12 files · 37.6 KBBrowse files →
Skill instructions
limrun-android-emulator17.4 KB
---
name: limrun-android-emulator
description: "Drive an app running on a Limrun cloud Android emulator: install an APK, launch and terminate apps with crash reports, tap, type, read the UI element tree, screenshot, record video, inject microphone audio, shape network bandwidth, read app logs, run shell commands, transfer files, tunnel the app's network destinations through your machine with HTTP inspection and HAR capture, and use adb over the CLI's tunnel for full logcat and interactive tools. Use after a build (from limrun-gradle or any builder) when the user wants to see, test, or interact with their app on an emulator, or says 'show me a screenshot', 'tap', 'run it on the emulator', 'check logcat', 'record a video', 'inspect network traffic', or 'reach my local server from the emulator'. To build the APK or AAB first, use limrun-gradle."
user-invocable: true
effort: high
---
# Limrun Android Emulator
Interact with an app running on a Limrun cloud Android emulator, from any
environment (Linux, Windows, macOS, VM, container). This skill is
build-agnostic: it assumes the APK was already built, usually by
**limrun-gradle**. Keep build concerns in that skill; this one is about
driving the running emulator.
Never use a local emulator, a local Android SDK, or Android Studio.
## Auth and CLI
Install if needed: `npm install --global lim`. Auth is `lim login` or
`LIM_API_KEY` (it may be set outside the project, so don't ask for it just
because it's missing from `.env` or the shell). The CLI is the source of truth:
the commands in this skill are verified, but if a flag errors or you need one
not shown here, check `lim android <subcommand> --help` instead of guessing.
## Installing an app
You can build with Limrun's remote Gradle service and have the APK installed
on a fresh emulator automatically, or install a pre-built local APK or a URL.
One default to know first: `lim android create` opens an ADB tunnel
(`--connect`) and a browser tab with the live stream (`--open`) unless told
otherwise. As an agent, pass `--no-open` always, and `--no-connect` unless you
need adb right away.
### Build and install
Upload the APK built by **limrun-gradle** as a named asset, then create an
emulator with it pre-installed:
```bash
lim gradle build . --upload myapp.apk
lim android create --install-asset myapp.apk --no-open --no-connect
```
Create blocks until the instance is ready to drive; no boot wait is needed.
The create output includes a signed stream URL; share it with the user as a
Markdown link, like `[Live emulator](<signed-stream-url>)`. If you have a
browser the user can see, open the URL there and tell them. Create also
prints a console URL: it opens the same live view but requires a console
login, so prefer the signed stream URL for sharing.
Useful create flags: `--reuse-if-exists` (reuse a running instance with the
same labels), `--rm` (delete when the CLI exits), `--jurisdiction us|eu|as`
(where the instance runs; don't use `--region`, it is deprecated),
`--inactivity-timeout` / `--hard-timeout`, `--display-name`, and `--label k=v`
(find labeled instances later with `lim android list --label-selector k=v`).
### Install app from local
Create a new emulator, then install a local file or a URL:
```bash
lim android create --no-open --no-connect
lim android install-app ./app-debug.apk
lim android install-app https://example.com/app.apk
```
A local path is uploaded to Limrun Asset Storage first; a URL is fetched by
the instance itself, which is much faster for large APKs than uploading from
your machine. `install-app` returns as soon as the app is sent; the install
finishes in the background within seconds. Newly installed apps land in the
app drawer, not the home screen, so don't look for their icon; just launch
the app with `lim android launch-app <package> --detach` (without `--detach`
it blocks watching the app until it exits), or confirm with
`lim android adb-shell -- sh -c "pm list packages | grep <name>"`.
Every time you need to install a new version of the APK, sync it instead of
reinstalling:
```bash
lim android sync ./app-debug.apk
```
It sends only a delta against the APK already on the instance, then
reinstalls. `--watch` keeps re-syncing on file changes, and
`--launch-mode ForegroundIfRunning|RelaunchIfRunning` controls what happens to
the running app after each install.
## Targeting the right instance
Most `lim android` commands default to the last created instance and resolve
the "current" one from the **git repo / worktree** of your cwd. In a different
directory (or outside any git repo) a command can report that no recent
instance was found even though one is running. The reliable recipe:
```bash
lim android list # shows all instances and their IDs
lim android element-tree --id <that-id> # pass --id to EVERY lim android command
```
Once you have the ID (format `android_<region>_<ulid>`), pass
`--id <android-instance-id>` to all `lim android` calls for the rest of the
session. Alternatively, `git init` the project so the workspace resolves on
its own. When controlling multiple instances, always pass `--id`.
## Launching the app
Launch and stop installed apps by package name:
```bash
lim android launch-app com.example.app --detach # launch and return
lim android launch-app com.example.app # launch and watch until it exits
lim android launch-app com.example.app --mode RelaunchIfRunning # restart for a clean state
lim android terminate-app com.example.app # stop it, e.g. to reset app state
```
Without `--detach`, `launch-app` blocks watching the app: when it crashes,
ANRs, or is stopped, the command prints the exit reason, crash details with
the stack trace, and a recent app log tail, then returns. That report is the
way to see why an app died without adb; for logs while the app is running,
use `lim android app-log` (below). There is no `list-apps`; discover package
names with `lim android adb-shell -- pm list packages`, or take the
application ID from the build.
## App logs
One app's logs need no tunnel:
```bash
lim android app-log com.example.app --tail 100 # recent lines (app must be running)
lim android app-log com.example.app --follow # stream live lines until Ctrl+C; don't stream into context
```
## Shell and files
One-shot shell commands and file transfer need no tunnel either:
```bash
lim android adb-shell -- pm list packages -3 # like adb shell; args go after --
lim android adb-shell -- sh -c "dumpsys battery | grep level" # pipes need an explicit shell
lim android push-file ./fixture.json /sdcard/Download/fixture.json
lim android pull-file /sdcard/Download/out.json ./out.json
```
They run with the same permissions the adb shell user has, and `adb-shell`
exits with the command's exit code.
## Full logcat and interactive adb over the tunnel
Full-device logcat and anything interactive (Android Studio, scrcpy,
streaming) go through plain `adb` over the CLI's tunnel. Start the tunnel in
a background shell and keep it alive:
```bash
lim android connect # prints "Tunnel started on 127.0.0.1:<port>."
```
`connect` runs `adb connect` for you (use `--adb-path` if adb isn't on PATH).
The printed `127.0.0.1:<port>` is the device serial; pass it with `-s` to
every adb call (`adb devices` also lists it):
```bash
SERIAL=127.0.0.1:<port>
adb -s $SERIAL logcat -d | tail -100 # dump recent full-device logs, don't stream into context
```
The tunnel lives and dies with the process that started it: when that shell
exits, `adb devices` shows the serial as `offline` while the instance keeps
running. Just run `lim android connect` again to get a new tunnel (the port
changes each time). Stale offline serials are harmless; `adb disconnect`
clears them.
## Testing changes
When emulator interaction is part of the task, test new or changed
functionality with the interaction commands after each install or sync. Focus
on what changed, plus a quick smoke test of core flows. Start by reading the
element tree to see what's on screen before acting:
```bash
lim android element-tree
```
The output is the raw UIAutomator XML hierarchy on a single line, so a plain
grep echoes the whole document. Split it into one node per line first, then
grep for the `text`, `resource-id`, `content-desc`, or `bounds` you need
rather than dumping the whole tree into context:
```bash
lim android element-tree | sed 's/></>\n</g' | grep -i "save"
```
## Interacting with the app
Prefer tapping by resource id, then by visible text or content description,
then coordinates as a last resort:
```bash
lim android tap-element --resource-id com.example.app:id/startButton
lim android tap-element --text "Save"
lim android tap-element --content-desc "Open menu"
lim android tap 360 800
```
Selector values match **exactly**, not by substring: `--text "Save"` does not
match a "Save draft" button. Copy the value verbatim from `element-tree`. A
selector that matches nothing fails in a couple of seconds with
`No element found for selector`. Other selectors: `--class-name`,
`--package-name`, `--index`, `--clickable`, `--enabled`, `--focused`, and
`--bounds-contains-x/y`; combine them to narrow a match. On web pages in the
browser, resource ids are the page's own DOM ids (like `searchIcon`) and are
often empty; select by `--text` plus `--class-name` there, or fall back to
coordinates from the node's `bounds`. To inspect matches without tapping, use
`find-element` with the same selectors:
```bash
lim android find-element --text "Save" # table of matches with bounds
```
For text input, target the field directly; no prior focus tap is needed.
`type` takes the same selectors as `tap-element` (`--class-name
android.widget.EditText --focused` works for a field with no resource id):
```bash
lim android type "hello world" --resource-id com.example.app:id/searchBox
lim android type "hello" --x 360 --y 400 # by coordinate
lim android press-key enter
lim android press-key backspace # --modifier shift/control/alt/command to combine
```
For scrolling and navigation:
```bash
lim android scroll down --amount 600
lim android scroll up --amount 300
lim android press-key back
lim android press-key home
lim android open-url "https://example.com" # opens in the default browser; also fires deep links
```
After every interaction, re-run `element-tree` to confirm the UI transitioned.
No sleep is needed between a tap and `element-tree`; the tap blocks until
done. A page load after `open-url` is asynchronous though: re-run
`element-tree` until the node you expect appears. A present but childless
`android.webkit.WebView` means the page is still loading, not a broken tree.
```bash
lim android element-tree
```
### When the element tree is empty
Some React Native and Expo apps expose no accessibility nodes at all, which
leaves `element-tree`, `tap-element`, and `find-element` blind (system dialogs
still expose nodes). Fall back to driving by pixels: take a screenshot, read
the coordinates of the target, and use `tap x y` / `type --x --y`. Screenshot
pixels map 1:1 to tap coordinates, so a button centered at (360, 1322) in the
image is tapped with `lim android tap 360 1322`. Re-screenshot after each
action to confirm the result.
## Screenshots and video
Screenshot takes a **positional path** (not `-o`):
```bash
lim android screenshot screenshot.png
lim android screenshot screenshot.png --id <android-instance-id>
```
Use the element tree for functional assertions (element existence, text, state
changes) and screenshots only for visual properties. For anything involving
motion (animations, gameplay, streaming UI), prefer video:
```bash
lim android record start # non-blocking
lim android record stop -o /tmp/recording.mp4
```
`record stop` accepts `--quality 5-10`. Recorded frames are half the
screenshot resolution, so read tap coordinates from screenshots, never from
video frames. For UI changes, include a demo video in the pull request so the
user can see it.
## Simulate the microphone with an audio file
For voice-driven flows (assistants, speech-to-text, audio calls), play a local
audio file as the emulator's microphone. The app hears the audio through its
normal capture pipeline:
```bash
lim android play-on-microphone ./fixtures/command.wav # loops by default
lim android play-on-microphone ./fixtures/command.mp3 --once
```
WAV and MP3 work. The file is pushed over adb, so this command needs a local
`adb` binary (`--adb-path` if it's not on PATH) and opens its own short-lived
tunnel. Camera injection is iOS-only; for camera-driven test flows use
**limrun-ios-simulator**.
## Shape network bandwidth
Test slow-network behavior by capping the instance's Wi-Fi bandwidth:
```bash
lim android set-wifi-bandwidth --down-kbps 1000 --up-kbps 500
lim android set-wifi-bandwidth --down-kbps 0 --up-kbps 0 # 0 clears the limit
```
## Tunnel the app's traffic through your machine
When the app must reach a service only your machine can reach (a local dev
server, a VPN-only staging API), or you need to see its HTTP traffic, start a
destination tunnel. Only the destinations you select are rerouted through
the machine running `lim`; everything else leaves the instance directly.
```bash
lim android tunnel --selector localhost:8080 --detach --id <android-instance-id>
```
- An exact selector (`localhost:port` or `IP:port`, port >= 1024) becomes a
listener on the emulator, also reachable as `10.0.2.2:<port>`; the app's
connections to it land on your machine and are dialed there.
- Domain selectors (`api.example.com`, `"*.corp.example"`) are intercepted
on the emulator and dialed from your machine, so your DNS and VPN apply.
Apps that resolve DNS themselves over HTTPS bypass domain interception.
- Start the tunnel **before** launching the app: connections opened earlier
keep their original route. One tunnel per instance; a second start fails.
As an agent, always pass `--detach`: it returns once the tunnel is READY and
keeps it alive in a background process. Manage it with:
```bash
lim android tunnel status --id <android-instance-id> # state, per-selector binds, last dial failure
lim android tunnel stop --id <android-instance-id>
```
### Inspect HTTP traffic, capture HAR, persist a network log
Inspection is on by default: every HTTP and HTTPS request through the tunnel
is decoded, printed as one summary line per request (in the tunnel log file
when detached), and shown live in the console's network panel.
```bash
lim android tunnel --selector "*.api.example" --har ./traffic.har --detach # write HAR 1.2 with bodies
lim android tunnel --selector "*.api.example" --persist --detach # network log survives the instance
```
`--persist` uploads a body-inclusive network log as a session artifact when
the tunnel stops or the instance terminates; it appears on the instance's
session page in the console with a HAR download (default lifetime 3 days,
`--ttl <seconds>` up to 30 days). HTTPS is decoded with an emulator-trusted
CA, so **apps with certificate pinning fail through inspected domain
selectors**: leave the pinned host out of the selectors or pass
`--no-inspect` to relay bytes opaquely (no summaries, HAR, or persistence).
## Preview URL for humans
Upload the APK to Limrun Asset Storage and return a preview URL for the user
to open and test the app manually in the browser:
```bash
export ASSET_NAME=myapp.apk # can be any name
lim asset push ./app-debug.apk -n ${ASSET_NAME}
echo "https://console.limrun.com/preview?asset=${ASSET_NAME}&platform=android"
```
Opening the link in the Limrun console provisions an emulator with the APK
pre-installed.
## Cleanup
When the work is completed, you can delete the emulator. `delete` takes a
**positional** ID (`--id` is not a valid flag here, unlike other commands):
```bash
lim android delete <android-instance-id>
```
## Gotchas
- **The fleet is x86_64.** Emulators report `x86_64,arm64-v8a` ABIs and run
arm64 code through translation, but an APK whose native libraries are
arm64-only for some vendor SDKs installs fine and then crashes with
`UnsatisfiedLinkError` when that code first loads. Build with x86_64 native
libs included.
- **Selectors match exactly.** `tap-element --text` and `find-element --text`
need the full, exact string from `element-tree`; substrings match nothing.
- **`install-app` returns before the install finishes.** The app lands a few
seconds later; verify with `find-element` or `pm list packages` before
launching. Prefer `install-app <URL>` or `create --install-asset` over raw
`adb install`: a big APK over `adb install` streams with zero progress
output and looks hung for minutes, and killing it mid-stream corrupts the
install.
- **The ADB tunnel is session-bound.** It dies with the shell that started it
while the instance keeps running; reconnect with `lim android connect` and
re-read the port, it changes every time.
- **Failed create pipes can still leak an instance.** If a `create` invocation
errors client-side (broken pipe, JSON parse), check `lim android list`; the
instance may exist anyway and should be deleted.
- **Empty element tree usually means a React Native app**, not a broken
instance. See "When the element tree is empty" above.
- **`element-tree` can be large.** Pipe through `grep` to extract what you
need rather than dumping the whole tree into context.
- **Instance resolution can miss in a non-git dir.** See "Targeting the right
instance" above; pass `--id` when in doubt.
- **Build errors are the build skill's job.** If the APK isn't building, the
failure is upstream; go back to **limrun-gradle**.
limrun-detox-testing5.83 KB
---
name: limrun-detox-testing
description: Configure, run, or debug Detox on Limrun iOS simulators. Use when attaching the Limrun Detox runtime to an app, wiring Detox mediator connectivity, or validating app/tester connections over destination tunnels.
user-invocable: true
---
# Limrun Detox
Use this for Detox runtime work on Limrun iOS. Keep build concerns separate
unless the user explicitly asks for a native build.
## Components
- Tester: local Node/Jest/Detox process.
- Mediator: `detox run-server`, usually local to the agent machine.
- App client: injected by limulator through `lim ios launch-app --runtime detox`.
## CLI Flow
Check current help before running commands you have not used in this session:
```bash
lim ios tunnel --help
lim ios launch-app --help
```
Run the long-lived mediator, tester, and tunnel from separate terminals.
Before using this quick path, ensure the project's `.detoxrc` reads the session
environment. Detox does not consume these variables automatically:
```js
session: {
server: process.env.DETOX_SERVER || 'ws://localhost:8099',
sessionId: process.env.DETOX_SESSION_ID || 'limrun-detox',
}
```
The complete configuration appears under **Detox Test Setup** below.
Terminal 1:
```bash
npx detox run-server -p 8099 -l verbose
```
Terminal 2:
```bash
lim ios tunnel \
--selector localhost:8099 \
--detach \
--id <ios-id>
DETOX_SERVER_URL="ws://localhost:8099"
```
Terminal 3 starts the tester before the app connects:
```bash
DETOX_SERVER="ws://localhost:8099" \
DETOX_SESSION_ID=<session-id> \
npx detox test --no-start
```
Then relaunch the app from Terminal 2. `--detox-version` is optional when
running from the project with `node_modules/detox`.
```bash
lim ios launch-app <bundle-id> \
--id <ios-id> \
--runtime detox \
--detox-server-url "$DETOX_SERVER_URL" \
--detox-session-id <session-id> \
--detox-version <detox-version>
```
Prefer starting the tester before the app connects, or use the maintained orchestration in [limrun-inc/typescript-sdk `examples/detox-ios`](https://github.com/limrun-inc/typescript-sdk/tree/main/examples/detox-ios), to avoid benign mediator "cannot forward" noise.
If you manually launch the app before `npx detox test --no-start`, that mediator message is expected until the tester connects.
If tunnel start reports an active session, inspect it with `lim ios tunnel status --id <ios-id> --json`. Stop an obsolete session with `lim ios tunnel stop --id <ios-id>`, then start the declared mediator selector again.
An open idle tunnel does not count as instance activity, so it does not prevent
the simulator's inactivity timeout between test runs.
## Detox Test Setup
`npx detox test --no-start` still needs the normal Detox project configuration:
- Pass the Detox config file and configuration name from your project (see [`examples/detox-ios/.detoxrc.cjs` in limrun-inc/typescript-sdk](https://github.com/limrun-inc/typescript-sdk/blob/main/examples/detox-ios/.detoxrc.cjs) for a reference layout).
- Use the Limrun third-party driver: `type: '@limrun/detox/driver'`.
- Keep `DETOX_SERVER` and `DETOX_SESSION_ID` aligned with the mediator and launch command.
- Provide Limrun driver env such as `LIMRUN_IOS_ID`, `LIMRUN_IOS_API_URL`, and `LIMRUN_IOS_TOKEN` when screenshots or driver calls need the instance API.
Use [limrun-inc/typescript-sdk `examples/detox-ios`](https://github.com/limrun-inc/typescript-sdk/tree/main/examples/detox-ios) as the maintained happy path for exact config/env wiring. Use `-l trace` on `detox run-server` only when verbose logs are not enough.
For native SwiftUI apps, a minimal Detox configuration usually looks like:
```js
const server = process.env.DETOX_SERVER || 'ws://localhost:8099';
const sessionId = process.env.DETOX_SESSION_ID || 'limrun-detox';
module.exports = {
testRunner: { args: { $0: 'jest' }, jest: { setupTimeout: 120000 } },
session: {
server,
sessionId,
debugSynchronization: 0,
},
apps: { ios: { type: 'ios.app', binaryPath: 'unused-by-limrun' } },
devices: {
limrun: {
type: '@limrun/detox/driver',
device: { id: process.env.LIMRUN_IOS_ID },
},
},
configurations: {
'ios.limrun': {
device: 'limrun',
app: 'ios',
behavior: { init: { reinstallApp: false }, cleanup: { shutdownDevice: false } },
},
},
};
```
Then launch with `lim ios launch-app <bundle-id> --runtime detox ...` and run `npx detox test --no-start`.
## Validation Signals
- App connected: `detox run-server` logs `role:"app"` and `appConnected:true`.
- Tester connected: the same session reaches `testerConnected:true, appConnected:true`.
- Runtime loaded: the app connects to the mediator after the `--runtime detox` launch.
- UI visible: `lim ios element-tree --id <ios-id>` shows the expected app screen.
## Gotchas
- Do not pass arbitrary env vars, app args, or injectable paths. Use `--runtime detox`.
- `--detox-version` should match the local `detox` package version used by the tester. If omitted, `lim ios launch-app` resolves it from the current working directory; pass it explicitly when running outside the Detox project.
- Unsupported bundled Detox versions should fail with a clear supported-version list.
- `Cannot forward the message to the Detox client` can simply mean the app connected before the tester did.
- For SwiftUI, prefer stable accessibility identifiers, e.g. `.accessibilityIdentifier("greetingText")` with `by.id('greetingText')`; `by.text(...)` can miss labels that appear in `lim ios element-tree`.
- Debug failures by checking `lim ios element-tree --id <ios-id>` first, then mediator logs for app/tester connection state.
- Cleanup manual runs by stopping `detox run-server`, running `lim ios tunnel stop --id <ios-id>`, and deleting the instance with `lim ios delete <ios-id>` (`--id` is not valid for delete).
- This does not make Detox own the iOS lifecycle; prepare or reuse the Limrun instance separately.
limrun-expo-development13.9 KB
---
name: limrun-expo-development
description: "Prepare and run Expo / React Native apps on Limrun with Expo dev-client iteration. Use when the user wants an Expo dev build, Metro tunnel, hot reload, JS/TS iteration without repeated native rebuilds, or to run/test an Expo app on a remote iOS simulator or Android emulator."
user-invocable: true
effort: high
---
# Developing Expo Apps on Limrun
Use this skill for Expo / React Native-specific setup and dev-client iteration, on iOS simulators and Android emulators. Use **limrun-ios-simulator** and **limrun-android-emulator** for command details, device interaction, screenshots, recordings, and cleanup, and the build skills (**limrun-xcode**, **limrun-gradle**) for build flag details and non-Expo workflows.
All builds and device operations must run on Limrun. Do not use local Xcode, local simulators, a local Android SDK, or local emulators; local `adb` is used only to talk to the remote emulator through the CLI's tunnel.
## Expo Readiness
Before changing Expo dependencies or app config, check the app's Expo SDK version and use the matching Expo versioned docs.
Verify this is an Expo app:
```bash
npx expo config --type introspect --json
```
Derive:
- `BUNDLE_ID` from `ios.bundleIdentifier` (iOS) and `PACKAGE` from `android.package` (Android). When `android.package` is missing, introspect reports a placeholder (like `com.placeholder.appid`) while the build generates a different real applicationId; set `android.package` in `app.json` before building so `$PACKAGE` matches the installed app.
- `SLUG` from `slug`
- `SCHEME` from `scheme`, falling back to `exp+${SLUG}`
- `BRANCH` from `git branch --show-current`, falling back to `main`
- `ASSET_NAME="${BUNDLE_ID}/${BRANCH}-debug.zip"` on iOS, `ASSET_NAME="${PACKAGE}/${BRANCH}-debug.apk"` on Android
## Ensure Dev Client
Expo development builds require `expo-dev-client`. If it is missing from `package.json`, install it automatically:
```bash
npx expo install expo-dev-client
```
Installing `expo-dev-client`, adding/removing/updating native dependencies, or changing native app config means the uploaded Debug asset is stale. Build a fresh Debug app before starting the dev loop. Do not merely warn the user that a rebuild may be needed; perform the rebuild.
## Debug Build Asset
First check whether a reusable Debug dev-client asset already exists:
```bash
lim asset list --name-prefix "$BUNDLE_ID/" # iOS
lim asset list --name-prefix "$PACKAGE/" # Android
```
Reuse the exact `$ASSET_NAME` only when:
- it exists, and
- no native dependency or native config changed in this session.
If the current task changed native dependencies or native config, skip asset reuse even if `$ASSET_NAME` exists.
When reusing the asset, create or reuse a device and install it:
```bash
lim ios create \
--reuse-if-exists \
--install-asset "$ASSET_NAME" \
--label repo=<repo> \
--label agent=<agent>
lim android create \
--reuse-if-exists \
--install-asset "$ASSET_NAME" \
--no-open \
--label repo=<repo> \
--label agent=<agent>
```
Android note: keep the tunnel that `create` opens by default (do not pass
`--no-connect` here, unlike plain driving sessions); the Metro reverse
tunnel below runs over it. Note the instance ID from the output and pass
`--id` to every later `lim android` call: Metro and Expo run from the app
directory, and instance resolution is per git worktree, so commands run from
elsewhere will not find the instance on their own.
### Fresh build on Android
Build the Debug APK remotely and upload it as the asset (Expo prebuild,
`--expo-app-dir`, and other build flags belong to **limrun-gradle**; the
default `assembleDebug` task is the right dev-client build):
```bash
lim gradle build . --upload "$ASSET_NAME"
lim android create --reuse-if-exists --install-asset "$ASSET_NAME" --no-open --label repo=<repo> --label agent=<agent>
```
For a later native rebuild on a running emulator, rebuild with `--upload` and
install the new APK via the Download URL the build prints (the instance
fetches it server-side):
```bash
lim gradle build . --upload "$ASSET_NAME"
lim android install-app "<Download URL from the build output>" --id <android-instance-id>
```
### Fresh build on iOS
When building fresh, create or reuse a standalone Xcode sandbox and build
before creating a simulator, so the simulator doesn't sit idle (and hit its
inactivity timeout) during a long build:
```bash
lim xcode create --reuse-if-exists --label repo=<repo> --label agent=<agent>
lim xcode build . \
--configuration Debug \
--upload "$ASSET_NAME"
```
Run `lim xcode version set <major>` once in the repo when the project needs a
specific Xcode major (e.g. 27 for the beta); see `limrun-xcode` for the rules.
Use `--expo-app-dir`, `--scheme`, or `--workspace` when the project layout requires it.
Then create the simulator attached to that Xcode target; the attach installs
and launches the build immediately:
```bash
lim ios create --attach \
--reuse-if-exists \
--label repo=<repo> \
--label agent=<agent>
```
Add `--no-open` to any `create` when you have no browser to show the user; it
skips opening the stream URL and leaves the URL in the output to share.
If an iOS simulator is already running from a reused asset and a later native rebuild becomes necessary, attach that same simulator instead of creating a second one:
```bash
lim xcode attach-simulator <ios-instance-id> --id <xcode-instance-id>
```
After the attach, every successful `lim xcode build` installs and launches the app on the attached simulator.
## Start Metro Through Limrun on iOS
This flow is for iOS. Android uses `adb reverse`; skip to **Start Metro on
Android** instead of running the `lim ios` commands.
Start one destination tunnel after the Debug app is installed. Metro can keep
its normal local port; Expo advertises localhost:
```bash
METRO_PORT=8081
lim ios tunnel \
--selector "localhost:${METRO_PORT}" \
--detach \
--id <ios-instance-id>
TUNNEL_URL="http://localhost:${METRO_PORT}"
echo "TUNNEL_URL=$TUNNEL_URL"
EXPO_PACKAGER_PROXY_URL="$TUNNEL_URL" \
npx expo start --dev-client --port "$METRO_PORT"
```
`EXPO_PACKAGER_PROXY_URL` keeps localhost and the declared port in manifests,
bundle URLs, and deep links. Set it inline so it takes precedence over project dotenv
values. Keep Metro and the detached tunnel running while the user iterates.
Run Metro as a managed background process, or copy the printed `TUNNEL_URL` into
a second terminal before launching the app.
If port 8081 is already occupied, choose another explicit port and use the same
value for the tunnel selector, `TUNNEL_URL`, and Expo's `--port`. Selector sets
are immutable: stop and recreate the tunnel with the complete selector list
when the port changes.
Only add `--offline` in a genuinely network-isolated environment after
dependencies are installed. Offline mode disables network checks and dependency
validation, so do not use it to compensate for ordinary Expo authentication.
### Launch the iOS dev client
Open the Debug app through the dev-client URL:
```bash
ENCODED_URL="$(node -e 'console.log(encodeURIComponent(process.argv[1]))' "$TUNNEL_URL")"
DEV_CLIENT_URL="${SCHEME}://expo-development-client/?url=${ENCODED_URL}"
lim ios open-url --id <ios-instance-id> "$DEV_CLIENT_URL"
```
If opening fails and the primary scheme came from `scheme`, retry once with
`exp+${SLUG}`. On a fresh instance, the iOS dev-menu onboarding sheet can
consume the first deep link; tap through it and open the URL again.
For Expo Go, replace `--dev-client` with `--go`, then open:
```bash
lim ios open-url \
--id <ios-instance-id> \
"exp://${TUNNEL_URL#http://}"
```
Tunnel lifecycle:
```bash
lim ios tunnel status --id <ios-instance-id> --json
lim ios tunnel stop --id <ios-instance-id>
```
One instance accepts one active destination tunnel. Stop the current tunnel
before starting another route set. When iteration ends, stop Metro with
`Ctrl+C` and stop the detached tunnel with the command above.
If the simulator attempts a route while Metro is stopped, the tunnel remains
active and status records a correlated `connection_refused`. Restart Metro with
the same proxy URL and reopen the dev-client URL; do not recreate the simulator
or tunnel.
## Start Metro on Android
Android uses `adb reverse` over the CLI's ADB tunnel. Metro stays on its default
port 8081, no packager hostname override is needed, and the emulator reaches
Metro at `http://127.0.0.1:8081`:
```bash
lim android connect --id <android-instance-id> # background shell; prints "Tunnel started on 127.0.0.1:<port>."
adb -s 127.0.0.1:<port> reverse tcp:8081 tcp:8081
npx expo start --dev-client --port 8081
DEV_CLIENT_URL="${SCHEME}://expo-development-client/?url=http%3A%2F%2F127.0.0.1%3A8081"
lim android open-url "$DEV_CLIENT_URL" --id <android-instance-id>
```
The ADB tunnel dies with the shell that started it, and the port changes on
every reconnect; re-run `adb reverse` with the new serial after any reconnect.
See **limrun-android-emulator** for tunnel details.
On the first launch, tap through the dev-menu onboarding sheet
(`lim android tap-element --text Continue`) and close the dev menu. The bundle
loads behind the native sheet.
## Fallback: Expo Tunnel
If the Limrun endpoint cannot be used, start Expo's public tunnel:
```bash
npm install --save-dev '@expo/ngrok@^4.1.0'
npx expo start --dev-client --tunnel
```
Use the complete dev-client URI Expo prints:
```bash
DEV_CLIENT_URL="<complete URI printed by Expo>"
lim ios open-url --id <ios-instance-id> "$DEV_CLIENT_URL"
lim android open-url "$DEV_CLIENT_URL" --id <android-instance-id>
```
## Legacy iOS fixed-port reverse tunnel
`lim ios reverse` remains available for workflows that already use the reserved
57090–57099 range. Expo dev-client can derive or advertise multiple packager
URLs, so mismatched mappings like `57090:8081` can leave some URLs pointing at
the local Metro port instead of the simulator-facing reverse endpoint.
Use the simulator-facing host printed by `lim ios reverse` in both `REACT_NATIVE_PACKAGER_HOSTNAME` and the encoded dev-client URL. Keep the reverse command running in a separate or background terminal while Metro is running:
```bash
lim ios reverse 57090:57090 --id <ios-instance-id>
REACT_NATIVE_PACKAGER_HOSTNAME=<reverse-host> \
npx expo start --dev-client --host lan --port 57090
ENCODED_URL="$(node -e 'console.log(encodeURIComponent(process.argv[1]))' "http://<reverse-host>:57090")"
DEV_CLIENT_URL="${SCHEME}://expo-development-client/?url=${ENCODED_URL}"
lim ios open-url --id <ios-instance-id> "$DEV_CLIENT_URL"
```
## Verify
For quick static validation, prefer:
```bash
npx tsc --noEmit
```
Only run `npm run lint` or `npx expo lint` when the repo already has ESLint configured. Expo lint can create ESLint config and mutate dependencies in projects that have not configured linting yet.
On iOS, use the element tree first:
```bash
lim ios element-tree
```
Success means the app UI is visible or the Expo dev menu shows it is connected to the tunnel. On a fresh instance the first dev-client launch can land on the dev-menu onboarding sheet covering the launcher: tap through it (`lim ios tap-element --ax-label Continue`), then open the dev-client URL again, since the first deep link is consumed by the sheet. If the tree does not confirm the connection, inspect app logs:
```bash
lim ios app-log "$BUNDLE_ID" --tail 100
```
On Android, verify with screenshots, not the element tree: Expo apps
typically expose no accessibility nodes there, so a rendered screen and an
empty tree coexist (see **limrun-android-emulator**):
```bash
lim android screenshot check.png --id <android-instance-id>
```
To see why the app died (crash, ANR), relaunch it watched; the command blocks
while the app runs (run it in a background shell) and prints the exit reason,
stack trace, and a recent app log tail when the app dies:
```bash
lim android launch-app "$PACKAGE" --mode RelaunchIfRunning --id <android-instance-id>
```
## Iterating
Once connected, JS/TS edits should update through Metro without another native build. If the task changes native dependencies, native config, or build settings, rebuild Debug before relaunching the dev loop.
Tell the user:
- device stream as a short Markdown link, for example `[Open simulator stream](<signedStreamUrl>)` or `[Open emulator stream](<signedStreamUrl>)`
- uploaded Debug asset name
- that JS/TS changes can now iterate through Metro
- that native changes require a new Debug build
## Final Preview
For a final shareable preview or PR demo, use a Release build so the user does not need Metro running:
```bash
ASSET_NAME="<bundle-id>/<pr-or-session>.zip"
lim xcode build . --configuration Release --upload "$ASSET_NAME"
ASSET_NAME="<package>/<pr-or-session>.apk"
lim gradle build . --task assembleRelease --upload "$ASSET_NAME"
```
Preview URL (`platform=android` for APK assets):
```text
https://console.limrun.com/preview?asset=${ASSET_NAME}&platform=ios
```
## Gotchas
- `npx expo start --dev-client` requires `expo-dev-client`; without it Expo cannot determine the development-build scheme.
- `No script URL provided` usually means the app is not a dev-client build or was launched without a dev-client URL.
- After a fresh native rebuild/install, a stale Metro/runtime error like `Cannot find native module` may come from the old app process. Relaunch the dev-client URL and verify with `element-tree` before assuming the rebuild failed.
- If a Debug build after adding a native dependency still behaves like the old native graph, that is unexpected Limrun behavior. Retry the build; creating a fresh build/device target is only a troubleshooting fallback.
- Expo tunnel startup can be flaky. Retry before changing the workflow.
- Do not reuse uploaded Debug assets after native dependency or native config changes.
- On Android, pass `--id <android-instance-id>` to every `lim android` call in this loop: instance resolution is per git worktree and the loop's commands run from mixed directories.
- An empty Android element tree while the screenshot shows the app is normal for Expo apps; verify by screenshot.
limrun-gradle10.9 KB
--- name: limrun-gradle description: "Build an Android app on a remote Gradle sandbox with `lim gradle build` instead of local Gradle or Android Studio, from any environment (Linux, Windows, macOS, VM, container). Use when the user wants to build an APK or AAB, sign a release with an upload key, prepare a Play Store publish, inspect build logs, or select sandbox tools and run shell commands, for native Android projects, React Native, and Expo. To run, tap, screenshot, or otherwise interact with the built APK on an emulator, use limrun-android-emulator. For iOS builds, use limrun-xcode or limrun-expo-development." user-invocable: true effort: high --- # Remote Gradle build Build Android projects on Limrun's remote Gradle sandboxes, from any environment (Linux, Windows, macOS, VM, container). `lim gradle build` syncs your sources to a remote instance, runs the project's own Gradle wrapper there, and streams the build output. Never fall back to local Gradle, a local Android SDK, or a local emulator. Your job doesn't end at a green build: get the app running or the artifact delivered, and iterate until the user is satisfied. For iOS builds, use **`limrun-xcode`** instead of this skill. For the Expo dev-client loop (Metro, hot reload) on either platform, use **`limrun-expo-development`**; it comes back here for the Android Debug build. ## Auth and CLI Install if needed: `npm install --global lim`. Auth is `lim login` or `LIM_API_KEY` (it may be set outside the project, so don't ask for it just because it's missing from `.env` or the shell). The CLI is the source of truth: the commands in this skill are verified, but if a flag errors or you need one not shown here, check `--help` instead of guessing: ```bash lim gradle --help lim gradle build --help ``` ## Build an APK Instead of `./gradlew`, build with: ```bash lim gradle build . ``` This creates or reuses the remembered Gradle instance, syncs the current directory, and runs `assembleDebug` by default. Pick tasks explicitly with `--task` (repeatable): ```bash lim gradle build . --task :app:assembleRelease ``` Use `--project-path` when the Gradle root is nested and auto-discovery is ambiguous (for example a bare React Native repo where Gradle lives in `android/`; the server usually finds it on its own): ```bash lim gradle build . --project-path android ``` Expo managed-workflow projects (no `android/` directory) are detected automatically: the sandbox installs dependencies and runs `expo prebuild` before Gradle. Setting `--expo-app-dir` (monorepos) or `--abi` forces that pipeline and errors when no Expo app is detected: ```bash lim gradle build ./my-monorepo --expo-app-dir apps/mobile ``` For iterating on an Expo app with Metro and hot reload rather than plain builds, use **`limrun-expo-development`**. ## Detached builds and logs Use `--detach` to return once the build is accepted; a webhook is optional. `logs` reads the latest build without an exec ID, including persisted logs after instance deletion; add `--follow` to wait for completion. ```bash lim gradle build . --detach lim gradle logs lim gradle logs --follow ``` ## Tool versions and shell commands After syncing, `lim gradle use` selects tools in the sandbox and installs missing versions. Run `lim gradle tools install` for synced project tool selections ([details](https://docs.limrun.com/docs/android/build-with-gradle)). Builds keep the project's `gradlew`; Android SDK/NDK/CMake use `sdkmanager`. ```bash lim gradle tools # Node includes npm/npx. lim gradle use node@24 pnpm@10 yarn@4 bun@1 java@temurin-17 bundletool@1 lim gradle tools install lim gradle run -- mise use --pin node@24.5.0 lim gradle run --env APP_ENV=staging -- npm run generate lim gradle build . --env APP_ENV=staging ``` ## Run it on an emulator Upload the built APK as a named asset, then install it on an Android instance: ```bash lim gradle build . --upload myapp.apk lim android create --install-asset=myapp.apk ``` Build uploads default to a 14-day TTL: each build pushes the asset's expiry to 14 days from that upload. Pass `--upload-ttl` with a Go duration (e.g. `720h`; `1d` is invalid) to change it. Share the signed stream URL from the create output with the user as a Markdown link, such as `[Live emulator](<signed-stream-url>)`. For rebuild iterations, patch the installed APK in place instead of recreating the instance: ```bash lim android sync ./path/to/app-debug.apk ``` For everything else on the device (tapping, typing, element tree, screenshots, video, logcat over adb), use **limrun-android-emulator**. ## Sign a release AAB The default signing path needs NO credentials from the user: ```bash lim gradle build . --sign --upload myapp.aab ``` On first use, Limrun generates an upload keystore, escrows it as the organization's signing key for this app, and signs with it. Every later `--sign` build of the same app, from any machine or CI, uses the same key, so Play Store uploads keep matching. The key is named by the Android application ID, detected from `app.json` (Expo) or `app/build.gradle(.kts)`; pass `--application-id <id>` when detection fails or picks the wrong flavor. `--sign` makes `bundleRelease` the default task and the build fails before starting if an explicit `--task` list contains no bundle task. A SUCCEEDED build means the AAB carries the signature (the server verifies it before upload), so don't re-verify the artifact unless the user asks. Expect one of these lines before the build starts and relay its meaning: - `Signing with the organization's upload key for <app> (newly generated).`: first build of this app; the key now exists for the whole organization. - `Signing with the organization's upload key for <app> (existing).`: reusing the escrowed key, as intended. ## Bring your own upload key When the app already has a registered upload key (an existing Play listing), sign with the user's keystore instead: ```bash lim gradle build . \ --keystore upload.jks --keystore-password "$KS_PASS" \ --key-alias upload --key-password "$KEY_PASS" \ --upload myapp.aab ``` All four flags travel together; the passwords can come from `LIM_KEYSTORE_PASSWORD` and `LIM_KEY_PASSWORD` instead of argv. Add `--save-key` to escrow the provided key so later builds can drop the flags and use plain `--sign`. `--save-key` refuses to overwrite: if a DIFFERENT key is already escrowed for the app it fails before any instance is created. Collect from the user: - the keystore file path (`.jks` or `.p12`); never commit it or paste its bytes into files, - the keystore password and the key password (often the same value), - the key alias (`keytool -list -keystore <file>` shows it if unknown). Failure strings to recognize on the bring-your-own path: - `The organization already has a different upload key escrowed for <app>`: `--save-key` conflict. Builds with `--sign` use the escrowed key; drop `--save-key` to sign with the provided keystore for this build only, or ask the user which key is the real upload key. - `Signing with your own key requires ... as well`: the BYO flag group is incomplete; the message lists exactly the missing flags. - `signing <field> contains an unsupported character`: the password or alias has characters outside ISO-8859-1. Change it in place with keytool (`-storepasswd`, `-keypasswd`, or `-changealias`) to a Latin-1 value. Never regenerate the key itself: that changes the upload key. ## Publish to Play Store With Play credentials (a service-account JSON via `--playstore-service-account`, or an access token via `--playstore-access-token`), the build publishes the signed release AAB directly, no browser involved: ```bash lim gradle build . --sign --upload-to-playstore --playstore-service-account sa.json --auto-version-code ``` `--auto-version-code` makes the server resolve the next free versionCode from Google Play before the build and stamp it into the workspace copy (`expo.android.versionCode` in app.json for Expo projects, the single literal `versionCode` in the conventional `app/` module build script for native Gradle projects), so repeat publishes never collide. Without it, or on projects with computed or flavor-split versionCodes (which it rejects at request time), manage the versionCode yourself as below. Without Play credentials you cannot run the publish itself: it is a browser flow with a Google sign-in. Prepare the artifact, upload it as an asset, and hand off: ```bash lim gradle build . --sign --upload <app>-v<versionCode>.aab ``` Tell the user to open https://console.limrun.com and, on the **Secrets** page, click **Connect Play Console** to sign in with a Google account that has release access to the app (the session lives in the browser only; nothing is stored). Then on the **Registry** page they click **Publish to Play Store** on the uploaded AAB and enter the package name (the application ID). The app listing must already exist in Play Console. Google Play requires a versionCode it has never seen: `--auto-version-code` handles that on publish builds; without it, bump `versionCode` in `app/build.gradle(.kts)` (Expo: `expo.android.versionCode` in app.json) before the build. Failure strings to recognize on the `--sign` path: - `Cannot determine the Android application ID for signing`: detection found no `app.json` android.package and no `applicationId` in `app/build.gradle(.kts)`; pass `--application-id <id>`. - `--sign produces a Play-ready signed AAB; include a bundle task`: the explicit `--task` list has no bundle task; add `bundleRelease` or drop `--task`. - `the built AAB carries no signature`: the server's post-build check found an unsigned bundle; the signing config was not applied. Not a problem in the user's code; retry, and report it if it persists. ## Gotchas - **Build errors are your job to fix.** If a build fails, read the error output, fix the code, and rebuild. Don't ask the user to fix build errors. - **Instance reuse is per git worktree.** Commands resolve the remembered instance from the worktree of your cwd; pass `--id <gradle-instance-id>` (from `lim gradle list`) to target a specific one. - **versionCode must increase for every Play upload.** Prefer `--auto-version-code` on publish builds. A rejected publish saying the version code already exists means bump, rebuild, republish. If a publish RETRY reports it, the earlier attempt already succeeded; don't publish again. - **Application ID detection reads the first uncommented `applicationId`.** Flavor-specific IDs and dynamic Gradle logic are out of its scope; use `--application-id` there. - **Keystore passwords must be non-empty and ISO-8859-1.** Empty passwords and characters outside Latin-1 are rejected at request time instead of failing minutes into the build. - **Keep synced files small and out of build dirs.** Root-level `build/`, `.gradle`, `.kotlin` and any `local.properties` never sync, and `.gitignore` files (including nested ones) are honored. Use `--ignore <regex>` for other large local artifacts and `--include <regex>` to force-sync gitignored inputs the build needs.
limrun-ios-simulator14.4 KB
---
name: limrun-ios-simulator
description: "Drive an app running on a Limrun cloud iOS simulator: launch, tap, type, read the accessibility element tree, screenshot, record video, connect the app to local services, play a video file as the camera, and run timed action chains. Use after a build (from any builder) when the user wants to see, test, or interact with their app on a simulator, or says 'show me a screenshot', 'tap', 'run the UI test', 'record a video', 'connect localhost', 'reach my local server from the simulator', 'mock the camera', or 'launch on simulator'. To build the app first, use limrun-xcode-bazel (Bazel workspaces) or limrun-xcode (xcodebuild projects)."
user-invocable: true
effort: high
---
# Limrun iOS Simulator
Interact with an app running on a Limrun cloud iOS simulator, from any
environment (Linux, Windows, macOS, VM, container). This skill is build-agnostic:
it assumes the app was already built and installed by a build skill
(`limrun-xcode-bazel` for Bazel, `limrun-xcode` for xcodebuild). Keep build
concerns in those skills; this one is about driving the running simulator.
Never use local Xcode, local simulators, or local macOS tools.
## Auth and CLI
Install if needed: `npm install --global lim`. Auth is `lim login` or
`LIM_API_KEY` (it may be set outside the project, so don't ask for it just
because it's missing from `.env` or the shell). The CLI is the source of truth:
the commands in this skill are verified, but if a flag errors or you need one
not shown here, check `lim ios <subcommand> --help` instead of guessing.
## Installing an app bundle
You can either use Limrun remote Bazel or Xcode services to build the app bundle and have
it installed to the simulator automatically or you can sync a pre-built local `.ipa`
file or `.app` folder to the simulator. The main requirement is that it must be
built for simulator.
### Build and install
A build skill usually attaches the simulator for you (`lim xcode rbe --ios`, or
`lim xcode build .` then attach). Check what's already there:
```bash
lim xcode get # is a simulator attached to the current build target?
lim ios list # all running iOS instances
```
If none is attached, create one. It installs the last build immediately, so you
don't need to rebuild:
```bash
lim ios create --attach
```
If the create (or `lim xcode rbe --ios`) output includes a signed stream URL,
share it with the user as a Markdown link, like
`[Live simulator](<signed-stream-url>)`. If you have a browser the user can see,
open the URL there and tell them. Otherwise pass `--no-open` to `create`: it
skips opening the URL locally and still prints it for sharing.
`lim xcode get` prints a Limrun console URL instead. It opens the same live
view but requires a console login, so prefer the signed stream URL for sharing.
If the console URL is all you have, share it and mention it needs login.
### Install app from local
Create a new simulator:
```bash
lim ios create
```
Share the signed stream URL with the user as a Markdown link, like
`[Live simulator](<signed-stream-url>)`. If you have a browser the user can see,
open the URL there and tell them.
You can then run the following command to upload a bundle from local:
```bash
lim ios sync <path to .ipa file or .app folder>
```
You can run the same command every time you need to install a new version of the
bundle. It will patch with the difference and reload it in the simulator.
## Targeting the right instance
Most `lim ios` commands default to the last created instance and resolve the
"current" one from the **git repo / worktree** of your cwd. So even when a
simulator is attached and `lim xcode get` shows it, a `lim ios` command can still
report `No instance ID provided and no recent ios instance found`, because your
cwd isn't the git worktree where the instance was created (or isn't a git repo at
all). This bites most often right after `lim xcode rbe --ios` in a fresh project.
The reliable recipe when that happens:
```bash
lim xcode get # shows the attached simulator's ID
lim ios element-tree --id <that-id> # pass --id to EVERY lim ios command
```
`lim xcode get` is the dependable source for the attached simulator's ID
(`lim ios list` also works). Once you have it, pass `--id <ios-instance-id>` to
all `lim ios` calls for the rest of the session (screenshot, tap, type,
element-tree, record). Alternatively, `git init` the project so the workspace
resolves on its own. When controlling multiple instances, always pass `--id`.
## Reaching services on the local machine
Destination tunnels let an iPhone simulator app keep calling its normal
destinations while the CLI dials them from the machine running `lim`. Select
exact `localhost:port` or literal `IP:port` destinations, or domains that only
your machine or VPN can reach:
```bash
lim ios tunnel \
--id <ios-instance-id> \
--selector localhost:3000 \
--selector localhost:8081 \
--selector "*.staging.example" \
--detach
```
Use the app's normal URLs, such as `http://localhost:3000`. Declaring
`localhost:3000` also captures loopback forms such as `127.0.0.1:3000` and
`[::1]:3000`, plus `[::ffff:127.0.0.1]:3000`. Domain selectors (exact
`api.corp.example` or label-bound wildcard `"*.staging.example"`) are
intercepted on the simulator and dialed from your machine whether or not the
name resolves on public DNS, so your DNS and VPN apply and TLS stays end to
end. Apps that resolve DNS themselves over HTTPS bypass domain interception.
A tunnel carries TCP only: up to ten exact selectors and 64 domain selectors,
ports 1-65535 except 53; CIDRs and UDP are not supported. Start the tunnel
before launching the app: connections opened earlier keep their original route.
One instance accepts one active destination tunnel, and its selector set is
immutable. To add or remove a destination, stop the tunnel and start it again
with the complete selector list:
```bash
lim ios tunnel status --id <ios-instance-id> --json
lim ios tunnel stop --id <ios-instance-id>
```
If the simulator attempts a route while its local service is stopped, the
tunnel remains active and reports `connection_refused`; restart the service
without recreating the simulator or tunnel.
## Launching the app
The build skills reinstall and relaunch the app after every successful build,
so you usually don't need to launch it yourself. When the app is closed (a
fresh attach to an old build, or after a terminate), launch it by bundle ID:
```bash
lim ios launch-app <bundle-id> # foregrounds it if already running
lim ios launch-app <bundle-id> --mode RelaunchIfRunning # restart for a clean state
lim ios terminate-app <bundle-id> # stop it, e.g. to reset app state
```
If you don't know the bundle ID, run `lim ios list-apps`.
The `lim ios launch-app` will stream the logs from the app in realtime for you
to debug. If you'd like to launch and forget, you can use `--detach` flag.
## Testing changes
When simulator interaction is part of the task, test new or changed
functionality with the interaction commands after each build. Focus on what
changed, plus a quick smoke test of core flows. Start by reading the element
tree to see what's on screen before acting:
```bash
lim ios element-tree
```
## Interacting with the app
Prefer tapping by accessibility id, then by label, then coordinates as a last
resort:
```bash
lim ios tap-element --ax-unique-id startButton
lim ios tap-element --ax-label "Save"
lim ios tap 201 450
```
`tap-element` taps with a real synthesized touch. Elements the accessibility
tree can see are scrolled into view automatically. A selector that matches
nothing in the tree fails in about a second; iOS creates list rows lazily, so
a below-the-fold row often isn't in the tree at all. For those, pass
`--scroll-search`: the CLI pages the screen (a few pages down, then up)
retrying the tap until the row materializes, which can take ~10s. Pass
`--activate ax` to use an accessibility press instead of a touch (no
scrolling, works on elements without a usable frame).
**Toolbar / nav-bar items usually can't be tapped by id.** SwiftUI collapses
toolbar children into a single nav-bar group, and those items report
`AXUniqueId: null` even when you set `.accessibilityIdentifier(...)` (regular
content `Button`s do expose it). So `tap-element --ax-unique-id` finds nothing
for a nav-bar button. Set an `.accessibilityLabel` / `.accessibilityIdentifier`
anyway for documentation, but to actually tap it, read its `AXFrame` from the
element tree and tap the center by coordinate:
```bash
lim ios element-tree --id <id> | grep -i -A6 -B2 moon # find the item's AXFrame
lim ios tap <x> <y> --id <id> # tap the frame's center
```
For text input, focus a field first (tap it), then type:
```bash
lim ios type "hello world" # real key events; errors if no field is focused
lim ios type "hi" --no-require-focus # skip the focus check: for fields focused by coordinate taps when the accessibility focus scan is unreliable
lim ios press-key backspace
lim ios press-key @ # shifted symbols work directly
```
`type` presses real keys, so text delegates fire and the field's own keyboard
behavior applies (a default text field autocapitalizes the first letter, for
example). To set a value verbatim with no keyboard behavior, use `set-text`:
```bash
lim ios set-text "P@ssw0rd!" --focused # into the focused field
lim ios set-text "hello" --ax-unique-id emailField # by selector
```
For scrolling and drags:
```bash
lim ios scroll down --amount 300 # from the screen center
lim ios scroll down --amount 300 --coordinate 200,400 # from a specific point
lim ios swipe --from 200,600 --to 200,200 # explicit drag; --duration 800 for a slower, precise one
```
After every interaction, re-run `element-tree` to confirm the UI transitioned.
No sleep is needed between a tap and `element-tree`; the tap blocks until done.
```bash
lim ios element-tree
```
Chain multiple actions with precise timing via `perform`:
```bash
lim ios perform --action type=tap,x=100,y=200 --action "type=typeText,text=Hello World"
lim ios perform --action type=wait,durationMs=1000 --action type=pressKey,key=enter
lim ios perform --file ./actions.yaml
```
Run `lim ios perform --help` for the full action grammar.
## Screenshots and video
Screenshot takes a **positional path** (not `-o`):
```bash
lim ios screenshot screenshot.png
lim ios screenshot screenshot.png --id <ios-instance-id>
```
Use the element tree for functional assertions (element existence, labels, state
changes) and screenshots only for visual properties. For anything involving
motion (animations, gameplay, streaming UI), prefer video:
```bash
lim ios record start # non-blocking
lim ios record stop -o /tmp/recording.mp4
```
For UI changes, include a demo video in the pull request so the user can see it.
## App container files
List an app's data container before pulling a file so you use the exact path the
app created. Keep the same `--bundle-id` and `--container-type` flags for list,
pull, push, and delete:
```bash
lim ios ls Documents --bundle-id com.example.app --container-type data
lim ios pull-file Documents/recording.mov ./recording.mov \
--bundle-id com.example.app --container-type data
lim ios push-file ./fixture.json Documents/fixture.json \
--bundle-id com.example.app --container-type data
lim ios delete-file Documents/fixture.json \
--bundle-id com.example.app --container-type data
```
`lim ios ls` defaults to the staging-folder root. With `--bundle-id`, it
defaults to the app bundle (`--container-type app`); use `data` for the app's
writable `Documents`, `Library`, and `tmp` directories. Paths in `ls` output are
relative to the selected root and can be copied directly into the other file
commands.
## Simulate the camera with a video
For camera-driven flows (QR-code scanning, document capture, video calls),
play a local video file as the simulator's camera. The app sees the frames
through its normal capture pipeline:
```bash
lim ios camera play ./fixtures/qr-scan.mp4 # loops by default
lim ios camera play ./fixtures/intro.mp4 --no-loop # play once, freeze on last frame
lim ios camera clear # restore the default camera
```
Any AVFoundation-decodable file works (H.264/HEVC in `.mp4`/`.mov`). Use
`--no-loop` when the app must observe the end of the clip exactly once (the
feed freezes on the last frame rather than stalling).
## Preview URL for humans
Upload the `.ipa` file directly or targz archive of the `.app` folder
to Limrun Asset Storage and return a preview URL for the user to open it and
test the app manually in the browser.
Here is how to upload it:
```bash
export ASSET_NAME=myapp.tar.gz # can be any name
lim asset push ${ASSET_NAME}
echo "https://console.limrun.com/preview?asset=${ASSET_NAME}&platform=ios"
```
Once the command finishes, you can give the following URL to the user to
click to see a simulator where this bundle is pre-installed.
```
https://console.limrun.com/preview?asset=${ASSET_NAME}&platform=ios
```
## Cleanup
When the work is completed, you can delete the iOS simulator.
```bash
lim ios delete
```
## Gotchas
- **Instance resolution can miss in a non-git dir.** See "Targeting the right
instance" above; pass `--id` when in doubt.
- **`element-tree` can be large.** Pipe through `grep` / `jq` to extract what you
need rather than dumping the whole tree into context.
- **`type` / `perform typeText` may not drive SwiftUI (or React Native) state.**
Automated text injection sets the field's value through accessibility, which
does **not** always fire a SwiftUI `@Binding` / `onChange` the way a real
keystroke does. Symptom: the text appears in the field (and in `element-tree`),
but reactive UI tied to it doesn't update (a send button stays disabled, a
character counter doesn't move) and submit handlers see empty state. A real
keyboard on the live stream works. When automating, drive submit through a
tappable control (a button, a suggestion chip) rather than relying on text
bound to reactive state, or have the app expose a test affordance.
- **Toolbar / nav-bar items aren't tappable by id.** See "Interacting with the
app" above: read the `AXFrame` from `element-tree` and tap by coordinate.
- **Bundle ID discovery.** If you don't know the bundle ID, run
`lim ios list-apps` after a successful install.
- **Build errors are the build skill's job.** If the app isn't installing, the
failure is upstream; go back to `limrun-xcode-bazel` / `limrun-xcode`.
limrun-maestro-testing6.67 KB
--- name: limrun-maestro-testing description: "Run Maestro YAML flows against a Limrun cloud iOS simulator with `lim ios maestro`, from any environment (Linux, Windows, macOS, VM, container). Use when the user wants to run, write, or debug Maestro flows or `maestro test` on iOS, migrate an existing Maestro suite to remote simulators, or asks for UI testing with Maestro. iOS simulators only today. For Detox suites use limrun-detox-testing; for driving the simulator without a test framework use limrun-ios-simulator." user-invocable: true --- # Maestro on Limrun iOS Run the stock upstream Maestro CLI against a remote Limrun iOS simulator. `lim ios maestro` wires `maestro test` to the instance transparently: it installs and launches the Maestro XCTest runner on the simulator when needed, then routes the driver traffic to it. No fork of Maestro, no local simulator, no local Xcode. ## Prerequisites - `lim` CLI 0.22.0 or newer: `npm install --global lim`. Auth is `lim login` or `LIM_API_KEY` (it may be set outside the project, so don't ask for it just because it's missing from `.env` or the shell). - Maestro CLI on PATH: `curl -fsSL https://get.maestro.mobile.dev | bash`. Maestro needs Java 17+ (`java -version` to check). Both Maestro 2.5.x and 2.6+ work; `lim` adapts to the installed version automatically. The CLI is the source of truth: if a flag errors or you need one not shown here, check `lim ios <subcommand> --help` instead of guessing. ## Verify the setup Before touching the user's app, prove the whole pipeline with a flow against the built-in Settings app; it needs no app install, tunnel, or build: ```bash ID=$(lim ios create --install-asset appstore/maestro-ios-runner-2.5.1.tar.gz \ --no-open --quiet --json | jq -r .metadata.id) cat > hello-flow.yaml <<'EOF' appId: com.apple.Preferences --- - launchApp - assertVisible: General - takeScreenshot: settings-check EOF lim ios maestro --id "$ID" test hello-flow.yaml ``` All three steps reporting `COMPLETED` means Maestro, the runner, and the remote wiring all work; anything failing after this point is about the app or the flow, not the setup. ## Run a flow ```bash lim ios maestro test flow.yaml lim ios maestro test flows/ lim ios maestro --id <ios-id> test flow.yaml ``` Without `--id` this targets the most recently created iOS instance in the current workspace (workspaces follow the git repo or worktree you run from); pass `--id <ios-id>` (before `test`) in scripts, agents, or when running from a different directory. The first run on an instance takes a few extra seconds to launch the runner (plus the install when it wasn't preinstalled); later runs skip that. Extra Maestro flags go after `--`: ```bash lim ios maestro -- test flow.yaml --include-tags smoke --test-output-dir artifacts ``` Do not pass `--platform`, `--device`, `--udid`, `--no-reinstall-driver`, or `--driver-host-port`; `lim` sets those itself and rejects duplicates. Real Maestro exit codes and reports are preserved, so CI wiring works as with local Maestro. ## Instance setup Any running iOS instance works; the runner is installed on first use. Creating the instance with the runner preinstalled skips that step: ```bash lim ios create --install-asset appstore/maestro-ios-runner-2.5.1.tar.gz --no-open ``` `--no-open` skips opening the stream URL in a browser (important on headless and CI machines). The runner asset name above is the only published one and it is version-agnostic: the same runner serves Maestro 2.5.x through 2.7.x, so do not look for an asset matching your Maestro version. Install the app under test as usual (`lim ios create --install app.ipa`, `lim ios install-app`, or a build skill), then reference its bundle id via `appId:` in the flow. For Expo Go testing, also preinstall `appstore/Expo-Go-54.0.6.tar.gz` and open the project URL with `openLink` (env vars must be prefixed `MAESTRO_` to be visible in flows): ```bash MAESTRO_EXPO_URL='exp://<tunnel-host>' lim ios maestro test flow.yaml ``` ## Expo dev-client builds Expo Go is the quickest path, but a dev-client build works too. `launchApp` lands on the dev launcher rather than your app, so open the dev-client URL instead: `- stopApp` followed by `- openLink: <scheme>://expo-development-client/?url=<url-encoded-metro-url>`. Use `limrun-expo-development` for building the dev client, starting Metro, and deriving that URL. ## Flow gotchas on Limrun - `startRecording`/`stopRecording` YAML commands are not supported (the simulator is remote). Record around the run instead: `lim ios record start --id <ios-id>` returns immediately (recording happens on the instance), and after the flow `lim ios record stop --id <ios-id> -o video.mp4` downloads the video to the local path. - `takeScreenshot` works and saves the PNG locally into the working directory (or `--test-output-dir`), like stock Maestro. - `addMedia` and flow commands that reference local simulator file paths are not supported. - HTTP calls from `runScript`/`evalScript` must use `https://` URLs. Plain `http://` calls are refused (except to the driver itself), because Maestro's plain-HTTP traffic is routed through a local bridge that only forwards to the remote simulator. - When a flow re-runs against an app that is already open (for example Expo Go), start it with `- stopApp` before `openLink`/`launchApp`; deep links can be dropped by an app that is mid-foreground, and stale screens fail early assertions. - Fleet variance: anchor assertions on stable accessibility identifiers and text, not on timing. Use `extendedWaitUntil` with a generous timeout for first app load. - Text selectors match the element's accessibility label, and on iOS a label often folds in sibling content (icon names, adjacent text) or non-breaking spaces. Read the exact label with `lim ios element-tree` before writing the selector instead of guessing from what the screen shows. ## Validation signals - `Running maestro <version> against <ios-id>...` then `Running on Limrun iPhone - iOS ...`: the driver is connected end to end. - `Launching the Maestro runner...`: first use on this instance. `Installing the Maestro runner...` additionally appears only when the instance was created without the runner asset. Subsequent runs skip both. - Maestro itself prints several JDK `WARNING` lines (reflection, native access) on every run; they are benign upstream noise, not Limrun errors. - Flow failures print Maestro's own debug output directory with screenshots and the UI hierarchy; `lim ios element-tree --id <ios-id>` shows the live screen when debugging selectors. ## Cleanup Delete the instance when done: `lim ios delete <ios-id>` (`--id` is not valid for delete). The wiring `lim ios maestro` starts is torn down when the command exits.
limrun-xcode20.5 KB
---
name: limrun-xcode
description: "Build an iOS / Apple app on remote Xcode with `lim xcode build` instead of local xcodebuild, or run its XCTest suites with `lim xcode test`, from any environment (Linux, Windows, macOS, VM, container). Use for non-Bazel projects (an `.xcodeproj` / `.xcworkspace`, an XcodeGen `project.yml` with a gitignored project, React Native / Expo native build) when the user wants to build, compile, test, inspect build logs, reload, produce a preview build, or ship a signed device IPA. To run, tap, screenshot, or otherwise interact with the result on a simulator, use limrun-ios-simulator. For Bazel workspaces, use limrun-xcode-bazel."
user-invocable: true
effort: high
---
# Remote Xcode build
Build Apple projects on Limrun's remote Xcode, from any environment (Linux,
Windows, macOS, VM, container). `lim xcode build` syncs your sources to a remote
Xcode instance, builds there, and (when a simulator is attached) installs and
relaunches the app. Never fall back to local Xcode, local simulators, or local
build tools. Your job doesn't end at a green build: get the app running, verify
it works, and iterate until the user is satisfied.
For driving the app once it's running (tap, type, element tree, screenshot,
record), use the **`limrun-ios-simulator`** skill. For Bazel workspaces, use
**`limrun-xcode-bazel`** instead of this skill.
## Auth and CLI
Install if needed: `npm install --global lim`. Auth is `lim login` or
`LIM_API_KEY` (it may be set outside the project, so don't ask for it just
because it's missing from `.env` or the shell). The CLI is the source of truth:
the commands in this skill are verified, but if a flag errors or you need one
not shown here, check `--help` instead of guessing:
```bash
lim xcode --help
lim xcode build --help
```
## Build
Instead of `xcodebuild`, build with:
```bash
lim xcode build .
```
This creates or reuses the remembered Xcode target, syncs the current directory,
and streams the build logs through stdout and stderr.
Use `--scheme` and `--workspace` if the project has multiple schemes or uses a
workspace file:
```bash
lim xcode build . --scheme MyApp --workspace MyApp.xcworkspace
```
Use `--configuration Debug` or `--configuration Release` for a specific Xcode
configuration. If omitted, Limrun uses limbuild's project-type default: `Debug`
for native Xcode builds, `Release` for React Native / Expo builds.
```bash
lim xcode build . --configuration Debug
```
### Detached builds and logs
Use `--detach` to return once the build is accepted; a webhook is optional.
`logs` reads the latest build without an exec ID, including persisted logs after
instance deletion; add `--follow` to wait for completion.
```bash
lim xcode build . --detach
lim xcode logs
lim xcode logs --follow
```
### Pick the Xcode version
A sandbox builds with its node's default Xcode. To build with another installed
major (Xcode 27 beta is available beside the default), set a preference once
for the workspace; every later build, test, RBE session and new sandbox follows
it, and the flag overrides it for one command:
```bash
lim xcode version list # versions the sandbox can build with; * marks the one in use
lim xcode use xcode@27 # prefer 27 for this workspace; switches the remembered sandbox now
lim xcode build . # builds with 27
lim xcode version # "27.0 (27A5252f)" shows the sandbox's current Xcode
lim xcode build . --xcode-version 26 # one-off override, not remembered
lim xcode version unset # forget the preference; the sandbox goes back to the node default
```
Combine Xcode and mise selections with `lim xcode use xcode@27 node@24`.
For scripting, `lim xcode version list --quiet` prints one selectable major per
line and `--json` returns `{ installed, bound, preferred }` (`installed[].betaSeed`
carries the beta seed). The table marks the Xcode in use with `*`.
`lim xcode version set` does not record a major the node lacks (the error lists
the available ones) but keeps it when the sandbox is merely busy.
When the sandbox is on another major than the workspace prefers, the next
build says so and switches it first. Switching invalidates the build cache made
with the other version (the next build starts cold) and is refused while a build,
sync or `lim xcode rbe` stack is running. A major the node does not have fails with the available list;
only majors are selectable. App Store uploads from a beta Xcode are rejected by
Apple, so keep `--upload-to-appstore` on the default.
`--dev-server-url` is only supported with `--configuration Debug` for React
Native / Expo builds. It's a post-install launch URL: limbuild validates it is a
parseable absolute URL, then opens it unchanged after installing on the attached
simulator. Framework-specific skills construct the correct URL.
```bash
lim xcode build . --configuration Debug --dev-server-url '<absolute-url>'
```
If the app launches without using the expected URL, open it explicitly to
separate build/install issues from URL routing:
```bash
lim ios open-url --id <ios-instance-id> '<absolute-url>'
```
## Developer tool versions
After syncing, `lim xcode use` selects tools in the sandbox and installs missing versions.
Run `lim xcode tools install` for synced project tool selections ([details](https://docs.limrun.com/docs/ios/build-with-xcode)). Use major versions, or major.minor for Ruby, Flutter, and pre-1.0 tools such as Mint.
```bash
lim xcode tools
# Node includes npm/npx, Ruby includes gem, Flutter includes Dart, CocoaPods includes cocoapods-patch.
lim xcode use node@24 pnpm@10 yarn@4 bun@1 ruby@3.3 bundler@4 cocoapods@1 \
cmake@3 java@jetbrains-21 corretto@21 flutter@3.44 mint@0.18 \
xcodegen@2 xcbeautify@3 zsign@1
lim xcode tools install
lim xcode use --cwd apps/mobile node@24
lim xcode tools install --cwd apps/mobile
lim xcode run -- mise use --pin node@24.5.0
```
## Generated Xcode projects (XcodeGen)
If the repo has a `project.yml` and the `.xcodeproj` is gitignored, do not run
xcodegen locally and do not treat the missing project as an error. The remote
sandbox generates the project from `project.yml` before building:
```bash
lim xcode build .
```
The spec is found at the repo root or one directory down (like `ios/`), no
flags needed. The project regenerates on every build, so `project.yml` edits
take effect by just rebuilding. A committed or force-synced `.xcodeproj`
always wins: the sandbox only generates when the sync didn't supply one.
If the repo's codegen produces gitignored inputs the build needs (a generated
local Swift package, config-derived sources), run that step locally first and
force-sync its output with `--include`:
```bash
make generate # or whatever the repo's codegen step is
lim xcode build . --include '^ios/GeneratedKit/'
```
`--include` takes a regular expression like `--ignore`, not gitignore syntax.
To reach files under a directory that is ignored as a whole, the pattern must
also match the directory path itself, as above.
## Run on a simulator
`lim xcode build` is build-and-install. Don't attach a simulator until the user
needs to see or interact with the app. Check / attach:
```bash
lim xcode get # is a simulator already attached?
lim ios create --attach # attach one (installs the last build immediately)
```
Add `--no-open` when you have no browser to show the user; it skips opening
the stream URL locally and still prints it for sharing.
If the attach output includes a signed stream URL, share it with the user as a
Markdown link, such as `[Live simulator](<signed-stream-url>)`.
When a simulator is attached, every successful `lim xcode build` automatically
reinstalls and relaunches the app, no separate install step. To tap, type, read
the element tree, screenshot, or record, switch to **`limrun-ios-simulator`**.
## Run tests (XCTest)
`lim xcode test` builds the scheme's test targets on the sandbox, runs them on
an attached simulator (unit and UI targets alike), and streams one line per
test case. The exec exits non-zero when any test fails, so it works as a CI
gate.
```bash
lim xcode test .
lim xcode test ./MyProject --scheme MyApp
```
It auto-acquires a simulator-backed target like `lim xcode build --ios` and
reuses the instances on repeat runs, so iterating is fast. The scheme must
have a test action configured (shared schemes from Xcode have one when the
project has test targets). `--xcode-version 27` builds the tests with that
Xcode; the simulator keeps the fleet default runtime, so the run warns and
proceeds (runtime-dependent failures are possible).
Select a subset with xcodebuild's identifier format
`Target[/Class[/method]]`; repeat the flag for multiple entries. The two flags
are mutually exclusive:
```bash
lim xcode test . --only-testing MyAppTests/LoginTests/testValidLogin
lim xcode test . --skip-testing MyAppUITests
```
A bare target name selects or skips that whole target. An `--only-testing`
entry naming a target the build did not produce fails the run instead of
silently running everything.
For machine consumption, `--json` streams the raw per-case events as NDJSON
and ends with a `{"exitCode": N}` record:
```bash
lim xcode test . --json > results.ndjson
```
`--build-only` compiles the test targets without acquiring a simulator; the
products stay on the sandbox for a later run.
If UI tests fail at the very first interaction on an instance that has run
many suites back to back, prefer fresh instances with
`--inactivity-timeout 30m` on the next run.
## Signed device builds (IPA)
Prefer Apple cloud signing when the user has an App Store Connect team API key.
Apple creates or reuses a cloud-managed certificate and provisioning profile,
so the user does not need to supply a p12 or `.mobileprovision`:
```bash
lim xcode build . --sdk iphoneos --configuration Release \
--signing-method release-testing --team-id "$APPLE_TEAM_ID" \
--asc-key-id "$ASC_KEY_ID" --asc-issuer-id "$ASC_ISSUER_ID" \
--asc-key AuthKey.p8 \
--upload myapp.ipa
```
Signing methods:
- `debugging`: development-signed IPA for registered development devices.
- `release-testing`: distribution-signed IPA for registered test devices.
- `app-store-connect`: distribution-signed IPA for App Store Connect.
Cloud signing requires a device SDK (`--sdk iphoneos` or `--sdk watchos`), a
team API key with an issuer ID, and `--team-id` matching that key's Apple
Developer team. For distribution methods,
the API key must be an Admin key or have **Access to cloud-managed distribution
certificates** enabled. A `Cloud signing permission error` means that permission
is missing. `No Account for Team` means the team ID and API key do not match.
`Failed Registering Bundle Identifier` means the bundle ID belongs to another
team and cannot be registered automatically.
Cloud signing takes entitlements only from `--entitlements`, never from the
project's `.entitlements` file: the archive is unsigned and the export
preserves entitlements only from an existing code signature. Any app using
capabilities (HealthKit, CloudKit, app groups, push) MUST pass the flag or
the capability is silently stripped from the IPA. A bare path targets the
app; `<bundleId>=<path>` targets an embedded bundle (widget, watch app);
repeat per bundle:
```bash
lim xcode build . --sdk iphoneos --configuration Release \
--signing-method release-testing --team-id VMBY3VYW4U \
--asc-key-id 2X9R4HXF34 --asc-issuer-id "$ASC_ISSUER_ID" \
--asc-key AuthKey.p8 \
--entitlements ./MyApp/MyApp.entitlements \
--entitlements com.example.myapp.widgets=./Widgets/Widgets.entitlements \
--upload myapp.ipa
```
The plist values must be fully expanded (no `$(AppIdentifierPrefix)`; write
the concrete prefix), must omit export-managed keys (`application-identifier`,
`com.apple.developer.team-identifier`, `get-task-allow`,
`beta-reports-active`), and every capability must be enabled on the App ID in
the developer portal or the export fails naming it.
Manual signing remains available when the user already has a p12 and profiles:
```bash
lim xcode build . --sdk iphoneos --configuration Release \
--certificate-p12 dist.p12 --certificate-password "$P12_PASSWORD" \
--provisioning-profile app.mobileprovision \
--upload myapp.ipa
```
The upload output includes a download URL for the signed IPA. A SUCCEEDED build
means the signature already passed Apple's verifier on the server, so don't
re-verify the IPA yourself unless the user asks. Invalid signing fails the
build loudly instead of producing a broken artifact.
If the app embeds extensions (WidgetKit widgets, share sheets, intents) or a
watch app, App Store signing needs one provisioning profile per bundle id, all
issued for the same distribution certificate. Repeat `--provisioning-profile`
once per bundle; each profile is matched to its bundle by the
application-identifier inside it, so order doesn't matter:
```bash
lim xcode build . --sdk iphoneos --configuration Release \
--certificate-p12 dist.p12 --certificate-password "$P12_PASSWORD" \
--provisioning-profile app.mobileprovision \
--provisioning-profile widgets.mobileprovision \
--upload myapp.ipa
```
With multiple profiles, every profile must carry an explicit (non-wildcard)
bundle id. A `signing preflight failed: no provisioning profile covers ...`
error names the embedded bundle that lacks a profile; ask the user for a
profile with exactly that bundle id.
Use a p12 that includes its full CA chain, not just the leaf certificate. If
needed, re-export it with the chain:
```bash
openssl pkcs12 -export -inkey dist.key -in dist.pem -certfile wwdr.pem -out dist-chain.p12
```
Failure strings to recognize in the build output:
- `Unknown issuer hash`: the p12 lacks its CA chain; re-export it with the
chain as above.
- `code signature verification failed`: the platform's post-sign check rejected
the artifact. Not a problem in the user's code; retry, and report it if it
persists.
- p12 password errors: `--certificate-password` doesn't match the file; ask the
user for the right password.
## Upload to App Store Connect
To upload the signed IPA to App Store Connect for TestFlight or App Store
distribution, pass `--upload-to-appstore` with the App Store Connect API key
flags on a signed device build:
```bash
lim xcode build . --sdk iphoneos --configuration Release \
--certificate-p12 dist.p12 --certificate-password "$P12_PASSWORD" \
--provisioning-profile app.mobileprovision \
--upload-to-appstore --asc-key-id "$ASC_KEY_ID" --asc-issuer-id "$ASC_ISSUER_ID" \
--asc-key AuthKey.p8
```
Cloud signing can sign and upload with the same API key:
```bash
lim xcode build . --sdk iphoneos --configuration Release \
--signing-method app-store-connect --team-id "$APPLE_TEAM_ID" \
--asc-key-id "$ASC_KEY_ID" --asc-issuer-id "$ASC_ISSUER_ID" \
--asc-key AuthKey.p8 \
--upload-to-appstore --auto-build-number
```
`--upload-to-appstore` requires either cloud signing or the manual signing
flags, plus `--asc-key-id` and `--asc-key`. Combine it with `--upload
<asset-name>` when the user also wants the IPA in Asset Storage.
Device IPAs carry the app's symbols (`Symbols/` next to `Payload/`), so App
Store Connect symbolicates crash reports without a separate dSYM upload. This
needs the build to produce dSYMs: `--configuration Release` does by default;
Debug does not, and the IPA then simply ships without symbols.
Collect from the user (all three live in App Store Connect under Users and
Access, Integrations tab, App Store Connect API):
- `--asc-key-id`: the Key ID next to their API key. If they don't have one,
point them at Team Keys with the **Developer** role: the least-privileged
role that can upload builds. Creating team keys needs an Admin account.
- `--asc-issuer-id`: the Issuer ID at the TOP of the Integrations page (a
team value, not per-key). Cloud signing requires a team key and this flag.
For manual signing plus upload, omit it for individual API keys.
- `--asc-key`: path to the downloaded `.p8` file. Apple keeps no copy and
the download link disappears after leaving the page; if the user lost it,
they must generate a new key. Never commit the `.p8` or paste its content
into files; pass a filesystem path.
By default the build returns as soon as the upload commits and leaves Apple's
processing verdict to App Store Connect (processing routinely takes many
minutes). Pass `--asc-wait-timeout <seconds>` (max 1800) to watch for the
verdict before returning. Read the outcome from the final lines:
- `App Store Connect: upload accepted.`: done; the build appears in
TestFlight once Apple finishes.
- `App Store Connect: uploaded, still processing on Apple's side (upload
<id>).`: the exit code is 0 and the upload succeeded; Apple is still
processing. Do NOT retry the build.
- `App Store Connect upload failed; the build and signing succeeded.`: exit
code 1 with Apple's error text earlier in the log. Only the delivery
failed.
Failure strings to recognize:
- Apple text about the bundle version being already used: bump
`CFBundleVersion` (Expo: `expo.ios.buildNumber` in app.json) and rebuild.
- `HTTP 401`: key ID / issuer ID / .p8 mismatch, or a revoked key.
- `HTTP 403`: the key's role cannot upload builds; it needs the Developer role
or higher.
- `no App Store Connect app with bundle id`: the app record doesn't exist;
the user must create it in App Store Connect manually (the API cannot).
- Build later stuck at "Missing Compliance" in TestFlight: the app doesn't
answer the export-compliance question at build time. Set
`ITSAppUsesNonExemptEncryption` to `NO` in Info.plist (Expo:
`expo.ios.config.usesNonExemptEncryption: false` in app.json) and rebuild.
For hands-off delivery to testers, the app's internal TestFlight group must
have automatic distribution enabled (create-only setting) and the compliance
key above must be set; then no post-upload steps exist at all.
## Preview builds
Only create a reusable preview asset when the user asks for a preview build or
when you're opening a PR. Build and upload:
```bash
ASSET_NAME="<bundle id / pr number / or any session identifier>.zip"
lim xcode build . --upload ${ASSET_NAME}
# Debug preview build:
lim xcode build . --configuration Debug --upload ${ASSET_NAME}
```
Build uploads default to a 14-day TTL: each build pushes the asset's expiry
to 14 days from that upload. Pass `--upload-ttl` with a Go duration (e.g.
`720h`; `1d` is invalid) to change it.
Then construct the preview link and include it in your last message (and in the
PR, if you're opening one):
```
https://console.limrun.com/preview?asset=${ASSET_NAME}&platform=ios
```
## Gotchas
- **Build errors are your job to fix.** If a build fails, read the error output,
fix the code, and rebuild. Don't ask the user to fix build errors.
- **Instance ID for `lim ios` commands.** They resolve the current instance
from the git worktree of your cwd and can fail with `No instance ID provided
and no recent ios instance found`. Get the ID from `lim xcode get` and pass
`--id <ios-instance-id>`; full recipe in limrun-ios-simulator's "Targeting
the right instance" section.
- **Bundle ID discovery.** If you don't know the bundle ID, check the Xcode
project files or run `lim ios list-apps` after a successful build.
- **Auth errors** on an authenticated command mean the session expired or
`LIM_API_KEY` is wrong; ask the user to run `lim login` or provide a key.
- **Build settings override Limrun's defaults.** `--build-setting KEY=VALUE`
accepts any environment-style key and replaces the managed value with the
same key. Device builds already use standard architectures (an embedded
watch app keeps `arm64_32`) and disable coverage instrumentation, so App
Store uploads need no extra settings.
- **Artifact not found after a successful build.** The server resolves the
built .app on its own, including when the scheme name differs from the
product name (scheme "MyApp Dev" building MyApp-dev.app). If an upload
still fails with `built artifact not found`, pass the full bundle filename
explicitly with `--artifact-name MyApp-dev.app` (including the .app
extension); the server then takes that name from the build products
verbatim.
- **Keep synced files small.** A single ~2MB+ file can fail the client-side
sync with ENOMEM before the build starts; compress large assets.
- **Symlinks sync when relative and in-root.** A symlink whose target is an
absolute path is skipped with a warning; recreate it with a relative target
if the build needs it. A relative link escaping the synced folder fails the
sync; `--ignore` it or sync from the repo root that contains the target.
- **Signing failures are loud and specific.** `Unknown issuer hash` means the
p12 lacks its CA chain, so re-export it with the chain; `code signature
verification failed` means the platform's post-sign check rejected the
artifact, which is not a code problem, so retry or report it.
limrun-xcode-bazel7.27 KB
--- name: limrun-xcode-bazel description: "Build a Bazel-based iOS / macOS / Apple app on Limrun's remote build execution (RBE) instead of a local Mac, and install it on a remote iOS simulator. Use when the project is a Bazel workspace (MODULE.bazel / WORKSPACE) building rules_apple / rules_swift targets and the user wants to `bazel build` it or run it on a simulator, or when a `--config=limrun` build or install misbehaves. To then tap, type, screenshot, or otherwise interact with the running app, use limrun-ios-simulator. For non-Bazel (plain xcodebuild) projects use limrun-xcode instead." user-invocable: true effort: high --- # Bazel iOS builds on Limrun RBE Build Bazel Apple projects on Limrun's remote Mac workers — from any environment (Linux, Windows, macOS, VM, container), no local Xcode. `lim xcode rbe` brings up a remote RBE stack, tunnels it to a local port, and writes a `.limrun/` config so `bazelisk build --config=limrun` runs Apple actions remotely. Never fall back to local Xcode or build tools. ## Auth and CLI Install if needed: `npm install --global lim`. Auth is `lim login` or `LIM_API_KEY` (may be set outside the project — don't ask for it just because it's absent). The CLI is the source of truth: the commands in this skill are verified, but if a flag errors or you need one not shown here, check `lim xcode rbe --help` instead of guessing. ## Build 1. From the **Bazel workspace root** (has `MODULE.bazel` / `WORKSPACE`), run `lim xcode rbe`. It sets up the instance + `.limrun/` config and **prints the exact build command**. The tunnel runs in the background (prints a PID); `--no-daemon` keeps it foreground. 2. Run the printed command, e.g. `bazelisk --digest_function=sha256 build --config=limrun //App`. Don't hand-write `.limrun/` or the flags — the CLI generates them for the sandbox's Xcode and your OS. `lim xcode version set 27` (or the one-off `lim xcode rbe --xcode-version 27`) builds with another installed major. Re-run `lim xcode rbe` (after `--stop`) to refresh after a fleet Xcode upgrade or an Xcode switch. To add your own Bazel flags to the limrun path without editing the generated config, put them in **`user.limrun.bazelrc`** at the workspace root. The generated config try-imports it last, so your `build:limrun --…` lines win, and it survives `lim xcode rbe` regeneration (`.limrun/` does not). ## Run on a simulator `lim xcode rbe` is build-only; attach a simulator when the user wants to see or run the app. Check or attach (it installs the last build immediately, so no rebuild is needed): ```bash lim xcode get # is a simulator already attached? lim ios create --attach # attach one ``` Add `--no-open` when you have no browser to show the user; it skips opening the stream URL locally and still prints it for sharing. If the attach output includes a signed stream URL, share it with the user as a Markdown link, such as `[Live simulator](<signed-stream-url>)`. With a simulator attached, every successful `--config=limrun` build automatically reinstalls and relaunches the app, no separate install step: ```bash bazelisk --digest_function=sha256 build --config=limrun //App ``` Notes: - **Attach upfront** if you already know you want a sim: `lim xcode rbe --ios` (attaches at startup, removed on `--stop`). - Auto-install happens server-side from the build's events; there is no `lim xcode rbe install` subcommand or `--target` flag. To force a reinstall, rebuild (a cache-hit rebuild is seconds). - It fires when the successful invocation produced a single app, so in a multi-app workspace build one app target per invocation (`//App`, not `//...`); a multi-app build succeeds but installs nothing. To tap, type, read the element tree, screenshot, or record the running app, switch to the **`limrun-ios-simulator`** skill. ## Upload builds as assets To publish a build as a Limrun asset (preview links, installing on other simulators, CI artifacts), arm uploads at tunnel start or upload one build after the fact: ```bash lim xcode rbe --auto-upload preview/my-app --upload-ttl 24h # every successful build refreshes the asset lim xcode rbe upload preview/my-app --ttl 24h # one-shot: the newest successful build ``` - `--auto-upload` holds for the tunnel's lifetime: each successful `--config=limrun` build re-uploads the app under that asset name, no post-build step. Upload results land in `.limrun/rbe.log`. - `rbe upload` runs from the workspace root and needs a background tunnel plus at least one successful build; it errors otherwise. - TTLs are Go durations (`24h`, `30m`; `1d` is invalid) and optional; each upload without one pushes the asset's expiry to 14 days from that upload. - To change the `--auto-upload` config of a running tunnel, `--stop` and re-run; the CLI refuses a mismatched re-arm instead of silently ignoring it. - Preview an uploaded app in a browser at `https://console.limrun.com/preview?asset=<name>&platform=ios`. ## Teardown Stop with **`lim xcode rbe --stop`** (~20s to tear the remote stack down) and delete the instance with **`lim xcode delete <id>`** ## Gotchas - **Always pass `--digest_function=sha256` before `build`** (use the command the CLI prints verbatim). The Limrun cache is SHA256-only; Bazel 9 defaults to BLAKE3. It's a startup flag, so it can't live in `--config=limrun`. Symptoms: build → `Cannot use hash function BLAKE3 with remote cache`; install → `non-SHA256 digest … rebuild with --digest_function=sha256`. - **Run `lim xcode rbe` from the workspace root**, not a subdirectory — it writes `.limrun/` there and fails fast otherwise. - **A green build doesn't prove remote execution** — cache hits (`action cache hit` / `remote cache hit`) make builds pass even with the tunnel gone. To force and verify real remote execution, see `references/verify-remote.md`. - **The printed `.ipa` path won't exist on your machine** — the build command carries `--remote_download_outputs=minimal`, which keeps the artifact in the instance's cache and downloads nothing. Bazel still prints its usual `Target //App:App up-to-date: …/App.ipa` line, but that file is **not** on disk. This is expected, not a failed build. With a simulator attached, the build auto-installs from the artifact's cache digest (read from the build event log, not the local file). Only if you genuinely need the `.ipa` locally, drop `--remote_download_outputs=minimal` from the build command and Bazel downloads the top-level output; auto-install keeps working either way. - **A fresh instance can fail the first build with `Lost inputs no longer available remotely`** (e.g. `… Assets.car`). It's a transient cache eviction between instances, not a code error; Bazel prints `Found transient remote cache error, retrying the build...` and the retry succeeds. To avoid hitting it mid-demo, pre-warm with a full build right after `lim xcode rbe`. - **`You don't have permission to save … in "CoreSimulator"`** (actool/ibtool) is a fleet-side device gap, not your config. Retry; if it persists, report it to Limrun. - **The project's own Bazel settings can fight RBE** (Xcode pinned via a Starlark transition, custom `remote_default_exec_properties`, sandbox-hostile genrules). These are per-project, not Limrun bugs — walk `references/project-compatibility.md` before concluding RBE is broken.
Referenced files: 2
Package details
Publisher declarations from the archived package. These are separate from our research and the live service's terms.
- Package author
- Limrun, Inc.
Package observed Sep 30, 2026.
Technical details
- First seen
- Sep 30, 2026 · 22:02 UTC
- Last seen
- Oct 2, 2026 · 00:00 UTC
- Collection status
- Collected
plugin_asdk_app_6aa95107d094819192324cfe1acf2985
Download plugin data (JSON)