expo-devbox 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brent Vatne
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,19 +1,23 @@
1
1
  # expo-devbox
2
2
 
3
- Build and test your Expo app's native iOS code on an EAS macOS worker instead of your own machine. You, or your coding agent, edit files locally; `expo-devbox build` syncs them to a worker that stays up for the session, runs an incremental Xcode build, launches the app on the worker's iOS Simulator, and reports what happened.
3
+ A macOS EAS worker for building and testing your Expo app's native iOS code, for you or your coding agent, instead of your own machine. `expo-devbox start` brings up a worker that has your app prebuilt, its pods installed, an iOS Simulator booted, Metro running, and a first build done. Then you, or your agent, copy files over with `expo-devbox sync` and run commands on it with `expo-devbox ssh`.
4
4
 
5
5
  It is for work that rebuilds native code over and over, such as an agent iterating on an [Expo module](https://docs.expo.dev/modules/overview/), when those rebuilds are too heavy for your machine.
6
6
 
7
+ ## Set it up with your coding agent
8
+
9
+ Paste this into your coding agent, in your Expo app:
10
+
11
+ ```text
12
+ Set up expo-devbox (https://www.npmjs.com/package/expo-devbox) in this Expo app. It is a tool I will ask you to use when I want to build and test native iOS code on an EAS macOS worker instead of this machine.
13
+
14
+ 1. Check the requirements: bun 1.3 or later, eas-cli logged in (`eas whoami`), and cloudflared (`brew install cloudflared` if it is missing). If the app has no EAS project ID in its app config, run `eas init`.
15
+ 2. Run `bunx expo-devbox init` in the app's root directory.
16
+ 3. Read the "Native iOS builds on EAS (expo-devbox)" section that init added to AGENTS.md or CLAUDE.md. If neither file exists, init prints the section instead; add it to the instructions file you read. Then run `bunx expo-devbox help`. Keep building the app the way you do now; use expo-devbox only when I ask you to.
17
+ 4. Do not start a session; a session bills EAS macOS minutes while it runs. Tell me what you set up.
7
18
  ```
8
- $ bunx expo-devbox build
9
- BUILD SUCCEEDED in 5s
10
- App console output from this launch:
11
- LOG [native-crypto] PASS sha384("abc"): cb00753f…
12
- LOG [native-crypto] DONE 7/7 passed
13
- Native log from this launch (system libraries excluded):
14
- 2026-10-24 16:59:04.278 Df myapp[6954:97f5] (Foundation) [native-crypto] module created
15
- Screenshot copied to .devbox/screenshot.png
16
- ```
19
+
20
+ **iOS only for now.** Android support, on an EAS Linux worker with the Android Emulator, will be added to this tool shortly.
17
21
 
18
22
  ## Requirements
19
23
 
@@ -29,98 +33,144 @@ In the root of your app:
29
33
  bunx expo-devbox init
30
34
  ```
31
35
 
32
- `init` does the following:
36
+ `init` writes the workflow to `.eas/workflows/ios-devbox.yml` and adds `.devbox/` to `.gitignore`; the CLI keeps the session's run ID and SSH config there. It also adds a short section for coding agents to `AGENTS.md` or `CLAUDE.md`, or prints it if neither file exists (see [Coding agents](#coding-agents)).
37
+
38
+ ## Commands
39
+
40
+ | Command | What it does |
41
+ | --- | --- |
42
+ | `init [--force] [--no-agents]` | Adds the devbox workflow to the app, as in [Set up](#set-up). `--force` replaces an existing workflow; `--no-agents` leaves `AGENTS.md` and `CLAUDE.md` alone. |
43
+ | `start [minutes] [idle-minutes]` | Starts a session and waits until the worker is ready, about 4 minutes. The session ends after `minutes` (default 60) or after `idle-minutes` with no SSH connection (default 15). |
44
+ | `sync` | Copies your files to the worker: what git would track, so `.gitignore` applies. Run it after each change. |
45
+ | `ssh ['command']` | Runs a command in the app's directory on the worker, or opens a shell. If the connection drops while the command runs, it exits 255 and does not run the command again. |
46
+ | `attach [run-id]` | Reconnects to a running session. `sync` and `ssh` also reconnect on their own when the worker's tunnel has moved. |
47
+ | `joblog` | Prints the workflow job's log. |
48
+ | `stop` | Ends the session and waits until EAS confirms that it has ended. |
49
+
50
+ `expo-devbox help` prints these commands and the steps in [On the worker](#on-the-worker).
51
+
52
+ ## On the worker
33
53
 
34
- - writes the workflow to `.eas/workflows/ios-devbox.yml`,
35
- - writes `devbox.json`,
36
- - adds `.devbox/` to `.gitignore`,
37
- - adds instructions for coding agents to `AGENTS.md` or `CLAUDE.md`. If neither file exists, it prints them instead; see [Coding agents](#coding-agents).
54
+ After each change, run `expo-devbox sync`, then run these with `expo-devbox ssh '<command>'`. The `DEVBOX_*` variables are set in every SSH session.
38
55
 
39
- Then:
56
+ For example:
57
+
58
+ ```
59
+ $ bunx expo-devbox sync
60
+ $ bunx expo-devbox ssh 'set -o pipefail; xcodebuild … build 2>&1 | tee ~/.devbox/build.log | grep -E ": (fatal )?error:|\*\* BUILD" | sort -u'
61
+ ** BUILD SUCCEEDED **
62
+ $ bunx expo-devbox ssh 'tail -n 3 ~/.devbox/metro.log'
63
+ LOG [native-crypto] PASS sha384("abc"): cb00753f…
64
+ LOG [native-crypto] DONE 7/7 passed
65
+ ```
66
+
67
+ **Build.** Prints errors with file and line, then `** BUILD SUCCEEDED **` or `** BUILD FAILED **`, and exits non-zero on failure. The full log is `~/.devbox/build.log`. After the worker's first build, an incremental build takes a few seconds.
40
68
 
41
69
  ```sh
42
- bunx expo-devbox start # start a session: 60 minutes at most, ends after 15 idle minutes
43
- bunx expo-devbox build # sync, build, launch, check
44
- bunx expo-devbox stop # end the session
70
+ set -o pipefail; xcodebuild -workspace "$DEVBOX_WORKSPACE" -scheme "$DEVBOX_SCHEME" -configuration Debug -destination "id=$DEVBOX_SIMULATOR" -derivedDataPath "$DEVBOX_DERIVED_DATA" build 2>&1 | tee ~/.devbox/build.log | grep -E ": (fatal )?error:|\*\* BUILD" | sort -u
45
71
  ```
46
72
 
47
- ## Commands
73
+ **Install and launch** the app on the simulator:
48
74
 
49
- | Command | What it does |
50
- | --- | --- |
51
- | `start [minutes] [idle-minutes]` | Starts the workflow and waits until SSH works (about a minute). The worker then prepares the project: install, `expo prebuild`, `pod install`, simulator, Metro, and a first build. That takes about three more minutes. |
52
- | `build` | Syncs your files, builds, launches the app, prints its console and native logs, and copies the screenshot to `.devbox/screenshot.png`. |
53
- | `build --pods` | Same, after adding or removing native source files or editing a podspec. |
54
- | `build --prebuild` | Same, after changing `package.json`, the app config or a config plugin. |
55
- | `ssh [command]` | Runs a command in the project directory on the worker, or opens a shell. |
56
- | `logs [pattern]` | Searches the app's console output from Metro. |
57
- | `sync` | Copies your files to the worker without building. |
58
- | `attach [run-id]` | Reconnects to a running session. `build` reconnects on its own. |
59
- | `joblog` | Prints the workflow job's log. |
60
- | `stop` | Cancels the session and waits until EAS confirms that it has ended. |
75
+ ```sh
76
+ xcrun simctl install booted "$DEVBOX_APP" && xcrun simctl launch --terminate-running-process booted "$DEVBOX_BUNDLE_ID"
77
+ ```
78
+
79
+ **JavaScript console output and errors**, from Metro:
61
80
 
62
- `build` exits non-zero, and says why, when:
81
+ ```sh
82
+ tail -n 50 ~/.devbox/metro.log
83
+ ```
63
84
 
64
- - the build fails: it prints the compiler errors with file and line,
65
- - the app is not running after launch: it prints the exception and the crashing frames,
66
- - the app does not log a line matching `waitFor`, or logs one matching `failOn`.
85
+ **Native log**: `NSLog`, and `os_log` or `Logger` output from the app's own code, from the last minute. Swift's `print` writes to stdout and does not appear here.
86
+
87
+ ```sh
88
+ xcrun simctl spawn booted log show --last 1m --style compact --predicate "$DEVBOX_LOG_PREDICATE" 2>/dev/null
89
+ ```
67
90
 
68
- ## devbox.json
91
+ **Check that the app is still running**, and if it crashed, read the exception and crashing frames from the newest crash report:
69
92
 
70
- | Key | Meaning |
93
+ ```sh
94
+ pgrep -lf "$DEVBOX_EXECUTABLE.app/$DEVBOX_EXECUTABLE" || echo "The app is not running."
95
+ tail -n +2 "$(ls -t ~/Library/Logs/DiagnosticReports/$DEVBOX_EXECUTABLE*.ips | head -1)" | jq "{exception, termination, frames: [.threads[] | select(.triggered) | .frames[:12][] | {symbol, sourceFile, sourceLine}]}"
96
+ ```
97
+
98
+ **Screenshot**, then copy it to your machine (the second command runs locally):
99
+
100
+ ```sh
101
+ xcrun simctl io booted screenshot ~/.devbox/screenshot.png
102
+ scp -F .devbox/ssh_config devbox:.devbox/screenshot.png .devbox/
103
+ ```
104
+
105
+ **After adding or removing native files, or editing a podspec:**
106
+
107
+ ```sh
108
+ cd ios && pod install
109
+ ```
110
+
111
+ **After changing `package.json`, the app config or a config plugin.** Use your package manager, and skip prebuild if you commit `ios/`:
112
+
113
+ ```sh
114
+ bun install && npx expo prebuild --platform ios --no-install && cd ios && pod install
115
+ ```
116
+
117
+ ### The variables
118
+
119
+ | Variable | Value |
71
120
  | --- | --- |
72
- | `waitFor` | A regular expression for a line your app logs when it is ready, for example when its self-test finishes. `build` waits for it, up to `waitSeconds`, and fails if it never appears. Without it, `build` waits 8 seconds. |
73
- | `failOn` | A regular expression for a line that means failure. `build` exits non-zero if the app logs it. |
74
- | `waitSeconds` | How long to wait for `waitFor`. The default is 60. |
75
- | `scheme` | The Xcode scheme, when it is not named after the `.xcworkspace`. |
121
+ | `DEVBOX_WORKSPACE`, `DEVBOX_SCHEME` | The Xcode workspace (`ios/<name>.xcworkspace`) and scheme. |
122
+ | `DEVBOX_SIMULATOR` | The booted simulator's UDID. |
123
+ | `DEVBOX_DERIVED_DATA` | The DerivedData folder of the worker's first build. Build with it to keep builds incremental. |
124
+ | `DEVBOX_APP`, `DEVBOX_BUNDLE_ID`, `DEVBOX_EXECUTABLE` | The built `.app`, its bundle identifier, and its executable's name. |
125
+ | `DEVBOX_LOG_PREDICATE` | A `log` predicate for messages from the app's own code and `NSLog`, without system libraries' logging. |
76
126
 
77
127
  ## Coding agents
78
128
 
79
- `init` adds the following to `AGENTS.md` or `CLAUDE.md`. Copy it into your agent's instructions if you use another file:
129
+ `init` adds this to `AGENTS.md` or `CLAUDE.md`. It tells agents to use the devbox only when you ask. Copy it into your agent's instructions if you use another file:
80
130
 
81
131
  ```markdown
82
132
  ## Native iOS builds on EAS (expo-devbox)
83
133
 
84
- Compile and test native iOS code on an EAS macOS worker with `bunx expo-devbox`, not on this machine. Do not run `npx expo run:ios` or `xcodebuild` locally.
134
+ `bunx expo-devbox` gives you a macOS EAS worker to build and test this app's native iOS code instead of this machine. Use it only when the user asks for it, for example "build this on the devbox". Otherwise, build and run the app as you normally would.
85
135
 
86
- 1. Start a session once: `bunx expo-devbox start`. It bills EAS macOS minutes until `stop`, its time limit (60 minutes by default), or 15 minutes without a connection.
87
- 2. After each change, run `bunx expo-devbox build`. Add `--pods` after adding or removing native source files or editing a podspec, and `--prebuild` after changing package.json, the app config or a config plugin.
88
- 3. `build` syncs your files, compiles, launches the app on the worker's iOS Simulator, and prints the app's console and native logs. It exits non-zero on a compile error (with file and line), a crash at launch (with the crashing frames), or a failed check from devbox.json. Read the output, fix the code, and build again. The screenshot is `.devbox/screenshot.png`.
89
- 4. `bunx expo-devbox ssh '<command>'` runs a command in the project directory on the worker, for example `xcrun simctl list devices booted`.
90
- 5. When you are done, run `bunx expo-devbox stop`.
91
- ```
136
+ When the user asks for it:
92
137
 
93
- To check results without reading pixels, have your app log a line when its checks pass or fail, and set `waitFor` and `failOn`.
138
+ 1. Run `bunx expo-devbox help` and follow its "On the worker" section. It has the command for each step: build, install and launch, logs, crash reports and screenshots.
139
+ 2. Start a session with `bunx expo-devbox start`. It returns when the worker is ready, and EAS macOS minutes are billed until the session ends.
140
+ 3. After each change, run `bunx expo-devbox sync`, then the commands from the help with `bunx expo-devbox ssh '<command>'`.
141
+ 4. When the user is done with the devbox, run `bunx expo-devbox stop`.
142
+ ```
94
143
 
95
- In a terminal that supports the [Program Status Protocol](https://mitchellh.com/writing/program-status-osc7501) (OSC 7501), such as Ghostty, `start`, `build` and `stop` report their status: working, done, error, or blocked when the run needs approval. The CLI sends these reports only when its stderr is a terminal, so output that an agent captures never contains them.
144
+ In a terminal that supports the [Program Status Protocol](https://mitchellh.com/writing/program-status-osc7501) (OSC 7501), such as Ghostty, `start`, `attach` and `stop` report their status: working, done, or blocked when the run needs approval on expo.dev; any command that fails reports an error. The CLI sends these reports only when its stderr is a terminal, so output that an agent captures never contains them.
96
145
 
97
146
  ## How it works
98
147
 
99
148
  The workflow runs one custom job on `macos-large` in the `development` environment:
100
149
 
101
- 1. **SSH.** It starts `sshd` as the build user on `127.0.0.1:2222`. Only the key `~/.ssh/eas_devbox`, created by `start`, can log in. SSH sessions get the toolchain variables (`PATH`, Ruby, nvm) but not the job's EAS environment variables or its `EXPO_TOKEN`. The job itself still has them, and the SSH user is the job user, so do not give a session to someone who should not see the development environment.
102
- 2. **Tunnel.** EAS workers accept no inbound connections, so the worker opens a pinned `cloudflared` [quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/), and Cloudflare relays your connection to it. The job log prints the tunnel host and the worker's SSH host key; the CLI reads them with `eas workflow:logs` and pins the key. Quick tunnels have no uptime guarantee. If the tunnel exits, the worker opens a new one and `build` reconnects.
103
- 3. **Preparation.** It runs `expo prebuild` (skipped when the project commits `ios/`) and `pod install`, and boots an iPhone simulator. It starts Metro with `CI` unset, because with `CI=true` Metro ignores file changes. Then it runs a first Debug build.
150
+ 1. **SSH.** It starts `sshd` as the build user on `127.0.0.1:2222`. Only the key `~/.ssh/eas_devbox`, created by `start`, can log in. SSH sessions get the toolchain variables (`PATH`, Ruby, nvm) and the `DEVBOX_*` variables, but not the job's EAS environment variables or its `EXPO_TOKEN`. The job itself still has them, and the SSH user is the job user, so do not give a session to someone who should not see the development environment.
151
+ 2. **Tunnel.** EAS workers accept no inbound connections, so the worker opens a pinned `cloudflared` [quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/), and Cloudflare relays your connection to it. The job log prints the tunnel host and the worker's SSH host key; the CLI reads them with `eas workflow:logs` and pins the key. Quick tunnels have no uptime guarantee. If the tunnel exits, the worker opens a new one, and `sync` and `ssh` reconnect.
152
+ 3. **Preparation.** It runs `expo prebuild` (skipped when the project commits `ios/`) and `pod install`, and boots an iPhone simulator. It writes the `DEVBOX_*` variables for SSH sessions. It starts Metro with `CI` unset, because with `CI=true` Metro ignores file changes. Then it runs the build command above once, so that later builds are incremental.
104
153
  4. **Session.** The job stays up until `minutes` after it started, or until no SSH connection has been open for `idle-minutes`. It restarts Metro if Metro stops responding.
105
154
 
106
155
  `sync` sends what git would track. Patterns from `.gitignore` are excluded, so local secrets such as `.env.local` stay local and files that the worker generated are not deleted. `.gitignore` lines that start with `!` are not supported.
107
156
 
108
- EAS has built-in workflow SSH (`eas workflow:run --ssh`) that is not available in production yet. When it is, it will replace the SSH and tunnel steps.
157
+ > [!NOTE]
158
+ > SSH support for EAS Workflows is coming soon (`eas workflow:run --ssh` and `eas workflow:ssh`). It will cover about half of what this tool does: the SSH server, the Cloudflare tunnel, finding the worker, and ending idle sessions. The other half stays: preparing the worker (prebuild, pods, simulator, Metro, a first build), syncing your files safely, the variables and commands for building and running the app, and the instructions for coding agents. Steps 1 and 2, and the idle shutdown in step 4, exist because EAS Workflows does not offer SSH yet. When workflow SSH ships, `expo-devbox` will use it.
109
159
 
110
160
  ## Limits
111
161
 
112
- - iOS only.
162
+ - iOS only. Android support will be added shortly.
113
163
  - One app per project root. Monorepos with dependencies outside the app directory are not supported.
114
- - `build --prebuild` reinstalls dependencies with the package manager whose lockfile is present.
164
+ - The `DEVBOX_*` values assume prebuild's naming, where the scheme, the app and its executable share a name. A project that commits `ios/` with different names can use its own values in the commands.
115
165
  - Private npm packages need their token as an EAS environment variable in the `development` environment.
116
166
 
117
167
  ## Turn off the intro
118
168
 
119
- `expo-devbox` and `expo-devbox init` open with an animated wordmark when you run them in a terminal. Set `EXPO_DEVBOX_INTRO=off` to skip it, or set it to `slider`, `decrypt`, `beams`, `snap` or `slide` to pick one.
169
+ `expo-devbox help` (or `expo-devbox` with no command) and `expo-devbox init` open with an animated wordmark when you run them in a terminal. Set `EXPO_DEVBOX_INTRO=off` to skip it, or set it to `slider`, `decrypt`, `beams`, `snap` or `slide` to pick one.
120
170
 
121
171
  ## Developing expo-devbox
122
172
 
123
- The CLI is at the root of this repository: `src/` holds the commands and the worker script, `templates/` holds the workflow that `init` writes, and `test/` holds the unit tests.
173
+ The CLI is at the root of this repository: `src/` holds the commands, `templates/` holds the workflow that `init` writes, and `test/` holds the unit tests.
124
174
 
125
175
  ```sh
126
176
  bun install
@@ -131,7 +181,7 @@ bun link && bun install -g . # put this checkout on your PATH as expo-devbox
131
181
 
132
182
  ### The example app
133
183
 
134
- [`example/`](example) is the app to test the CLI against, end to end, on a real EAS worker. It has a local Expo module, [`modules/native-crypto`](example/modules/native-crypto). The module wraps CryptoKit SHA-256, SHA-384, SHA-512 and HMAC-SHA256, and `SecRandomCopyBytes`. On launch, [`App.tsx`](example/App.tsx) checks the module against published test vectors (FIPS 180-2 and RFC 4231). It logs a `[native-crypto] PASS` or `FAIL` line for each check, then `[native-crypto] DONE`. [`devbox.json`](example/devbox.json) makes `build` wait for `DONE` and fail on `FAIL`.
184
+ [`example/`](example) is the app to test the CLI against, end to end, on a real EAS worker. It has a local Expo module, [`modules/native-crypto`](example/modules/native-crypto). The module wraps CryptoKit SHA-256, SHA-384, SHA-512 and HMAC-SHA256, and `SecRandomCopyBytes`. On launch, [`App.tsx`](example/App.tsx) checks the module against published test vectors (FIPS 180-2 and RFC 4231). It logs a `[native-crypto] PASS` or `FAIL` line for each check, then `[native-crypto] DONE`, and `NSLog`s `[native-crypto] module created` from Swift.
135
185
 
136
186
  The example does not depend on `expo-devbox`. Run the linked CLI from the example's directory:
137
187
 
@@ -139,23 +189,21 @@ The example does not depend on `expo-devbox`. Run the linked CLI from the exampl
139
189
  cd example
140
190
  bun install
141
191
  expo-devbox start
142
- expo-devbox build
143
- expo-devbox stop
144
192
  ```
145
193
 
146
194
  To use your own EAS account, delete `owner` and `extra.eas` from `example/app.json` and run `eas init` in `example/`.
147
195
 
148
- Before a release, run these checks in the example. Edit the app, run `expo-devbox build`, and check the exit code and the message; then revert the edit.
196
+ Before a release, run these checks in the example with the commands from [On the worker](#on-the-worker), and revert each edit afterwards:
149
197
 
150
198
  | Change | Expected result |
151
199
  | --- | --- |
152
- | None | exit 0, `DONE 7/7 passed`, the `module created` line in the native log |
153
- | A wrong expected value in `App.tsx` | exit 1, `matching failOn` |
154
- | A Swift type error | exit 1, the error with file and line |
155
- | `fatalError()` in the module's `OnCreate` | exit 1, the crash report frames |
156
- | `build --prebuild` | exit 0 |
200
+ | None: sync, build, install and launch | `** BUILD SUCCEEDED **`; Metro log ends with `DONE 7/7 passed`; the native log has `module created` |
201
+ | A Swift type error | the build prints the error with file and line, `** BUILD FAILED **`, and exits non-zero |
202
+ | `fatalError()` in the module's `OnCreate` | `The app is not running.`; the crash report frames point at `NativeCryptoModule.swift` |
203
+ | A wrong expected value in `App.tsx` | Metro log has a `FAIL` line |
204
+ | Prebuild and pod install, then build | `** BUILD SUCCEEDED **` |
157
205
  | `.env.local` present, then `sync` | the file is not on the worker |
158
- | `expo-devbox ssh 'pkill -9 cloudflared'`, then `build` | `build` reconnects on its own, exit 0 |
206
+ | `expo-devbox ssh 'pkill -9 cloudflared'`, then `expo-devbox ssh 'true'` | the first exits 255 with the dropped-connection message and is not run again; the second reconnects on its own and exits 0 |
159
207
  | `stop` | `The devbox session is canceled.` |
160
208
 
161
209
  After you change `templates/ios-devbox.yml`, run `expo-devbox init --force --no-agents` in `example/` to update its workflow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-devbox",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Build and test an Expo app's native iOS code on an EAS macOS worker, from your terminal or a coding agent.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -1,30 +1,60 @@
1
1
  #!/usr/bin/env bun
2
2
  import { init } from './init';
3
3
  import { playIntro } from './intro';
4
- import { attach, build, joblog, logs, shell, start, stop, sync, UserError } from './session';
4
+ import { attach, joblog, shell, start, stop, sync, UserError } from './session';
5
5
  import { status } from './status';
6
6
 
7
- const HELP = `Build and test an Expo app's native iOS code on an EAS macOS worker,
8
- from your terminal or a coding agent.
7
+ const HELP = `A macOS EAS worker for building and testing an Expo app's native iOS code,
8
+ for you or a coding agent. Run these in the app's root directory.
9
9
 
10
- Usage: expo-devbox <command>
10
+ Session:
11
+ init [--force] [--no-agents] add the devbox workflow to this app
12
+ start [minutes] [idle-minutes] start a session and wait until the worker is ready (about 4 minutes).
13
+ It ends after [minutes] (default 60) or after [idle-minutes] without
14
+ an SSH connection (default 15). EAS macOS minutes are billed until then.
15
+ sync copy your files to the worker (what git would track; .gitignore applies)
16
+ ssh ['command'] run a command in the app's directory on the worker, or open a shell
17
+ attach [run-id] reconnect to a running session
18
+ joblog print the workflow job's log
19
+ stop end the session and wait until EAS confirms it ended
11
20
 
12
- init [--force] [--no-agents] add the devbox workflow to this Expo app
13
- start [minutes] [idle-minutes] start a session (default 60 and 15); wait until SSH works
14
- build [--pods|--prebuild] sync, build with Xcode, launch, print logs, copy the screenshot
15
- ssh [command] run a command in the project on the worker, or open a shell
16
- logs [pattern] search the app's console output
17
- sync copy local sources to the worker
18
- attach [run-id] reconnect to a running session
19
- joblog print the workflow job's log
20
- stop cancel the session and wait until it has ended
21
+ On the worker:
22
+ After each change, run expo-devbox sync, then the commands below with expo-devbox ssh '<command>'.
23
+ The DEVBOX_* variables are set in every SSH session.
24
+
25
+ Build. Prints errors with file and line, then ** BUILD SUCCEEDED ** or ** BUILD FAILED **.
26
+ The full log is ~/.devbox/build.log.
27
+ set -o pipefail; xcodebuild -workspace "$DEVBOX_WORKSPACE" -scheme "$DEVBOX_SCHEME" -configuration Debug -destination "id=$DEVBOX_SIMULATOR" -derivedDataPath "$DEVBOX_DERIVED_DATA" build 2>&1 | tee ~/.devbox/build.log | grep -E ": (fatal )?error:|\\*\\* BUILD" | sort -u
28
+
29
+ Install and launch the app on the simulator:
30
+ xcrun simctl install booted "$DEVBOX_APP" && xcrun simctl launch --terminate-running-process booted "$DEVBOX_BUNDLE_ID"
31
+
32
+ JavaScript console output and errors (Metro):
33
+ tail -n 50 ~/.devbox/metro.log
34
+
35
+ Native log (NSLog, and os_log or Logger output from the app's own code; last minute):
36
+ xcrun simctl spawn booted log show --last 1m --style compact --predicate "$DEVBOX_LOG_PREDICATE" 2>/dev/null
37
+
38
+ Check that the app is still running:
39
+ pgrep -lf "$DEVBOX_EXECUTABLE.app/$DEVBOX_EXECUTABLE" || echo "The app is not running."
40
+
41
+ If it crashed, the exception and crashing frames from the newest crash report:
42
+ tail -n +2 "$(ls -t ~/Library/Logs/DiagnosticReports/$DEVBOX_EXECUTABLE*.ips | head -1)" | jq "{exception, termination, frames: [.threads[] | select(.triggered) | .frames[:12][] | {symbol, sourceFile, sourceLine}]}"
43
+
44
+ Screenshot (then, on your machine: scp -F .devbox/ssh_config devbox:.devbox/screenshot.png .devbox/):
45
+ xcrun simctl io booted screenshot ~/.devbox/screenshot.png
46
+
47
+ After adding or removing native files, or editing a podspec:
48
+ cd ios && pod install
49
+
50
+ After changing package.json, the app config or a config plugin (use your package manager;
51
+ skip prebuild if you commit ios/):
52
+ bun install && npx expo prebuild --platform ios --no-install && cd ios && pod install
21
53
 
22
54
  Requires a logged-in eas-cli and cloudflared (brew install cloudflared).
23
55
  Docs: https://www.npmjs.com/package/expo-devbox`;
24
56
 
25
- const commands: Record<string, (args: string[]) => unknown> = {
26
- init, start, build, ssh: shell, logs, sync, attach, joblog, stop,
27
- };
57
+ const commands: Record<string, (args: string[]) => unknown> = { init, start, sync, ssh: shell, attach, joblog, stop };
28
58
 
29
59
  const [name, ...args] = process.argv.slice(2);
30
60
  if (!name || name === 'help' || name === '--help' || name === '-h') {
package/src/init.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
 
4
4
  import { UserError, WORKFLOW } from './session';
@@ -8,13 +8,14 @@ const TEMPLATE = join(import.meta.dir, '..', 'templates', 'ios-devbox.yml');
8
8
  /** The section `init` adds to AGENTS.md or CLAUDE.md, and prints when neither exists. */
9
9
  export const AGENT_INSTRUCTIONS = `## Native iOS builds on EAS (expo-devbox)
10
10
 
11
- Compile and test native iOS code on an EAS macOS worker with \`bunx expo-devbox\`, not on this machine. Do not run \`npx expo run:ios\` or \`xcodebuild\` locally.
11
+ \`bunx expo-devbox\` gives you a macOS EAS worker to build and test this app's native iOS code instead of this machine. Use it only when the user asks for it, for example "build this on the devbox". Otherwise, build and run the app as you normally would.
12
12
 
13
- 1. Start a session once: \`bunx expo-devbox start\`. It bills EAS macOS minutes until \`stop\`, its time limit (60 minutes by default), or 15 minutes without a connection.
14
- 2. After each change, run \`bunx expo-devbox build\`. Add \`--pods\` after adding or removing native source files or editing a podspec, and \`--prebuild\` after changing package.json, the app config or a config plugin.
15
- 3. \`build\` syncs your files, compiles, launches the app on the worker's iOS Simulator, and prints the app's console and native logs. It exits non-zero on a compile error (with file and line), a crash at launch (with the crashing frames), or a failed check from devbox.json. Read the output, fix the code, and build again. The screenshot is \`.devbox/screenshot.png\`.
16
- 4. \`bunx expo-devbox ssh '<command>'\` runs a command in the project directory on the worker, for example \`xcrun simctl list devices booted\`.
17
- 5. When you are done, run \`bunx expo-devbox stop\`.
13
+ When the user asks for it:
14
+
15
+ 1. Run \`bunx expo-devbox help\` and follow its "On the worker" section. It has the command for each step: build, install and launch, logs, crash reports and screenshots.
16
+ 2. Start a session with \`bunx expo-devbox start\`. It returns when the worker is ready, and EAS macOS minutes are billed until the session ends.
17
+ 3. After each change, run \`bunx expo-devbox sync\`, then the commands from the help with \`bunx expo-devbox ssh '<command>'\`.
18
+ 4. When the user is done with the devbox, run \`bunx expo-devbox stop\`.
18
19
  `;
19
20
 
20
21
  export function init(args: string[]) {
@@ -33,11 +34,6 @@ export function init(args: string[]) {
33
34
  done.push(`Wrote ${WORKFLOW}.`);
34
35
  }
35
36
 
36
- if (!existsSync('devbox.json')) {
37
- writeFileSync('devbox.json', `${JSON.stringify({ waitFor: '', failOn: '' }, null, 2)}\n`);
38
- done.push('Wrote devbox.json. Set waitFor and failOn to regular expressions that match lines your app logs, so build waits for them and fails on them.');
39
- }
40
-
41
37
  const gitignore = existsSync('.gitignore') ? readFileSync('.gitignore', 'utf8') : '';
42
38
  if (!/^\/?\.devbox\/?$/m.test(gitignore)) {
43
39
  appendFileSync('.gitignore', `${gitignore && !gitignore.endsWith('\n') ? '\n' : ''}\n# expo-devbox session state\n.devbox/\n`);
@@ -62,6 +58,6 @@ export function init(args: string[]) {
62
58
  console.log(`
63
59
  Next:
64
60
  ${linked ? '' : 'eas init link this project to EAS, if it is not linked yet\n '}brew install cloudflared the SSH tunnel client
65
- bunx expo-devbox start start a session (EAS macOS minutes are billed while it runs)
66
- bunx expo-devbox build build, launch and check the app on the worker`);
61
+ bunx expo-devbox help the commands, and how to build and run the app on the worker
62
+ bunx expo-devbox start start a session (EAS macOS minutes are billed while it runs)`);
67
63
  }
package/src/session.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * The session commands: start a devbox workflow run, connect to its worker
3
- * over SSH through the Cloudflare tunnel it opens, sync sources, build, and
4
- * stop it. Run from the project root.
3
+ * over SSH through the Cloudflare tunnel it opens, sync files, run commands,
4
+ * and stop it. Run from the app's root directory.
5
5
  */
6
6
  import { $ } from 'bun';
7
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
9
  import { join, resolve } from 'node:path';
10
10
 
@@ -15,8 +15,9 @@ const STATE = '.devbox';
15
15
  const SSH_CONFIG = join(STATE, 'ssh_config');
16
16
  const RUN_ID = join(STATE, 'run_id');
17
17
  const KEY = join(homedir(), '.ssh', 'eas_devbox');
18
- const WORKER_SCRIPT = join(import.meta.dir, 'worker-build.ts');
19
18
  const ACTIVE = new Set(['NEW', 'WAITING', 'IN_PROGRESS', 'ACTION_REQUIRED']);
19
+ /** ssh's exit code when it could not connect, as opposed to the remote command's own code. */
20
+ const SSH_FAILED = 255;
20
21
 
21
22
  /** An error whose message is meant for the person or agent running the command. */
22
23
  export class UserError extends Error {}
@@ -25,8 +26,6 @@ const fail = (message: string): never => {
25
26
  };
26
27
  const log = (message: string) => console.error(message);
27
28
  const sleep = (seconds: number) => Bun.sleep(seconds * 1000);
28
- /** Quotes a value for the worker's shell. */
29
- const quote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
30
29
 
31
30
  type Run = { status: string; jobs?: { id: string }[] };
32
31
 
@@ -43,7 +42,7 @@ async function viewRun(runId: string): Promise<Run | undefined> {
43
42
  async function requireActive(runId: string): Promise<Run | undefined> {
44
43
  const run = await viewRun(runId);
45
44
  if (run && !ACTIVE.has(run.status)) {
46
- fail(`The devbox session ended with status ${run.status}. See: eas workflow:view ${runId}`);
45
+ fail(`The devbox session ended with status ${run.status}. Start a new one with: expo-devbox start`);
47
46
  }
48
47
  if (run?.status === 'ACTION_REQUIRED') {
49
48
  status('blocked', { kind: 'permission', msg: 'The devbox workflow run needs approval on expo.dev' });
@@ -57,14 +56,10 @@ function savedRun(): string {
57
56
  }
58
57
 
59
58
  function requireProject() {
60
- if (!existsSync(WORKFLOW)) fail(`${WORKFLOW} is missing. Run expo-devbox init in the project root first.`);
59
+ if (!existsSync(WORKFLOW)) fail(`${WORKFLOW} is missing. Run expo-devbox init in the app's root directory first.`);
61
60
  mkdirSync(STATE, { recursive: true });
62
61
  }
63
62
 
64
- function ssh(remoteCommand: string) {
65
- return $`ssh -F ${SSH_CONFIG} devbox ${remoteCommand}`;
66
- }
67
-
68
63
  /**
69
64
  * Reads the tunnel host, user and host key from the job log and writes an SSH
70
65
  * config for them. The last DEVBOX_SSH_HOST wins, because the worker starts a
@@ -103,7 +98,7 @@ async function connect(runId: string): Promise<void> {
103
98
  }
104
99
  await sleep(10);
105
100
  }
106
- fail(`Could not reach the worker for 15 minutes. See: eas workflow:view ${runId}`);
101
+ fail('Could not reach the worker for 15 minutes. See: expo-devbox joblog');
107
102
  }
108
103
 
109
104
  function writeSshConfig(host: string, user: string, hostKey: string) {
@@ -124,19 +119,30 @@ function writeSshConfig(host: string, user: string, hostKey: string) {
124
119
  `);
125
120
  }
126
121
 
127
- /** Waits for the worker to finish preparing the project. Reconnects if the worker is unreachable. */
122
+ /**
123
+ * Reconnects first if the worker is unreachable, for example because its
124
+ * tunnel moved. A command is never run twice: if the connection drops while
125
+ * it runs, it may already have done its work.
126
+ */
127
+ async function ensureConnected() {
128
+ const probe = await $`ssh -F ${SSH_CONFIG} devbox true`.quiet().nothrow();
129
+ if (probe.exitCode === SSH_FAILED) await connect(savedRun());
130
+ }
131
+ const droppedMessage = 'The connection to the worker dropped while the command ran. It may have finished on the worker; check before you run it again.';
132
+
133
+ /** Waits for the worker to prepare the app: install, prebuild, pods, simulator, Metro and a first build. */
128
134
  async function waitUntilReady(runId: string) {
135
+ log('Waiting for the worker to prepare the app (about 3 minutes)...');
136
+ status('working', { msg: 'The EAS worker is preparing the app' });
129
137
  const deadline = Date.now() + 20 * 60_000;
130
138
  while (Date.now() < deadline) {
131
- const result = await ssh('test -f ~/.devbox/ready').quiet().nothrow();
139
+ const result = await $`ssh -F ${SSH_CONFIG} devbox ${'test -f ~/.devbox/ready'}`.quiet().nothrow();
132
140
  if (result.exitCode === 0) return;
133
- if (result.exitCode === 255) {
141
+ if (result.exitCode === SSH_FAILED) {
134
142
  await connect(runId);
135
143
  continue;
136
144
  }
137
145
  await requireActive(runId);
138
- log('Waiting for the worker to finish preparing the project...');
139
- status('working', { msg: 'The EAS worker is preparing the project' });
140
146
  await sleep(15);
141
147
  }
142
148
  fail('The worker is not ready after 20 minutes. See: expo-devbox joblog');
@@ -149,7 +155,7 @@ export async function start(args: string[]) {
149
155
  const previous = readFileSync(RUN_ID, 'utf8').trim();
150
156
  const run = await viewRun(previous);
151
157
  if (run && ACTIVE.has(run.status)) {
152
- fail(`A devbox session is already running (run ${previous}). Use expo-devbox attach or expo-devbox stop.`);
158
+ fail(`A devbox session is already running (run ${previous}). Use it, or end it with: expo-devbox stop`);
153
159
  }
154
160
  }
155
161
  const [minutes = '60', idleMinutes = '15'] = args;
@@ -163,7 +169,7 @@ export async function start(args: string[]) {
163
169
  if (result.exitCode !== 0) fail(`eas workflow:run failed:\n${result.stderr.toString().trim()}`);
164
170
  const runId: string = JSON.parse(result.stdout.toString()).id;
165
171
  writeFileSync(RUN_ID, runId);
166
- log(`Started run ${runId}: ${minutes} minutes at most, ending after ${idleMinutes} idle minutes.`);
172
+ log(`Started run ${runId}: ${minutes} minutes at most, ending after ${idleMinutes} minutes without an SSH connection.`);
167
173
 
168
174
  // Cancel the run if this command fails or is interrupted, so it does not bill with nobody connected.
169
175
  const cancel = () => $`eas workflow:cancel ${runId} --non-interactive`.quiet().nothrow();
@@ -176,6 +182,7 @@ export async function start(args: string[]) {
176
182
  process.once('SIGINT', onInterrupt);
177
183
  try {
178
184
  await connect(runId);
185
+ await waitUntilReady(runId);
179
186
  } catch (error) {
180
187
  log(`Cancelling run ${runId}.`);
181
188
  await cancel();
@@ -183,8 +190,8 @@ export async function start(args: string[]) {
183
190
  } finally {
184
191
  process.removeListener('SIGINT', onInterrupt);
185
192
  }
186
- log(`The worker is still preparing the project. 'expo-devbox build' waits for it.`);
187
- status('done', { msg: 'Connected to the EAS worker' });
193
+ console.log('The worker is ready. Run expo-devbox help and follow "On the worker" to build and run the app.');
194
+ status('done', { msg: 'The EAS worker is ready' });
188
195
  }
189
196
 
190
197
  export async function attach(args: string[]) {
@@ -192,7 +199,9 @@ export async function attach(args: string[]) {
192
199
  const runId = args[0] ?? savedRun();
193
200
  writeFileSync(RUN_ID, runId);
194
201
  await connect(runId);
195
- status('done', { msg: 'Connected to the EAS worker' });
202
+ await waitUntilReady(runId);
203
+ console.log('The worker is ready.');
204
+ status('done', { msg: 'The EAS worker is ready' });
196
205
  }
197
206
 
198
207
  /**
@@ -209,40 +218,11 @@ export async function sync() {
209
218
  .split('\n').filter(Boolean).map(path => `/${path}`);
210
219
  const excludes = join(STATE, 'rsync-excludes');
211
220
  writeFileSync(excludes, [...gitignore, ...ignored].join('\n') + '\n');
212
- await $`rsync -az --delete -e ${`ssh -F ${SSH_CONFIG}`} --exclude .git --exclude node_modules --exclude /.devbox \
213
- --exclude /ios/Pods --exclude-from=${excludes} ./ devbox:project/`;
214
- }
215
-
216
- export async function build(args: string[]) {
217
- requireProject();
218
- const [option] = args;
219
- if (option && option !== '--pods' && option !== '--prebuild') fail('Usage: expo-devbox build [--pods|--prebuild]');
220
- const runId = savedRun();
221
- status('working', { msg: 'Waiting for the EAS worker' });
222
- await waitUntilReady(runId);
223
- status('working', { msg: 'Syncing sources to the EAS worker' });
224
- await sync();
225
- await $`scp -q -F ${SSH_CONFIG} ${WORKER_SCRIPT} devbox:.devbox/worker-build.ts`;
226
-
227
- status('working', { msg: option ? `Building on the EAS worker (${option.slice(2)})` : 'Building on the EAS worker' });
228
- const screenshot = join(STATE, 'screenshot.png');
229
- rmSync(screenshot, { force: true });
230
- const result = await ssh(`cd ~/project && bun ~/.devbox/worker-build.ts ${option ?? ''}`).nothrow();
231
- if ((await $`scp -q -F ${SSH_CONFIG} devbox:.devbox/screenshot.png ${screenshot}`.quiet().nothrow()).exitCode === 0) {
232
- console.log(`Screenshot copied to ${screenshot}`);
233
- }
234
- const summary = result.stdout.toString().match(/BUILD (SUCCEEDED|FAILED)[^\n]*/)?.[0];
235
- if (result.exitCode === 0) {
236
- status('done', { msg: summary ?? 'Build succeeded' });
237
- } else {
238
- status('error', { msg: summary?.startsWith('BUILD FAILED') ? summary : 'The app failed its checks; see the output' });
239
- }
240
- process.exitCode = result.exitCode;
241
- }
242
-
243
- export async function logs(args: string[]) {
244
- requireProject();
245
- await ssh(`grep -a -E ${quote(args[0] ?? '.')} ~/.devbox/metro.log | tail -50`).nothrow();
221
+ await ensureConnected();
222
+ const { exitCode } = await $`rsync -az --delete -e ${`ssh -F ${SSH_CONFIG}`} \
223
+ --exclude .git --exclude node_modules --exclude /.devbox --exclude /ios/Pods --exclude-from=${excludes} \
224
+ ./ devbox:project/`.nothrow();
225
+ if (exitCode !== 0) fail(`rsync exited ${exitCode}. Run expo-devbox sync again; it only sends what changed.`);
246
226
  }
247
227
 
248
228
  export async function shell(args: string[]) {
@@ -253,13 +233,16 @@ export async function shell(args: string[]) {
253
233
  process.exitCode = await child.exited;
254
234
  return;
255
235
  }
256
- process.exitCode = (await ssh(`cd ~/project && ${args.join(' ')}`).nothrow()).exitCode;
236
+ await ensureConnected();
237
+ const { exitCode } = await $`ssh -F ${SSH_CONFIG} devbox ${`cd ~/project && ${args.join(' ')}`}`.nothrow();
238
+ if (exitCode === SSH_FAILED) console.error(droppedMessage);
239
+ process.exitCode = exitCode;
257
240
  }
258
241
 
259
242
  export async function joblog() {
260
243
  requireProject();
261
244
  const jobId = (await viewRun(savedRun()))?.jobs?.[0]?.id;
262
- if (!jobId) fail('Could not find the job of the devbox run. See: eas workflow:view ' + savedRun());
245
+ if (!jobId) fail(`Could not find the job of the devbox run. See: eas workflow:view ${savedRun()}`);
263
246
  await $`eas workflow:logs ${jobId} --non-interactive`.nothrow();
264
247
  }
265
248
 
@@ -139,9 +139,26 @@ jobs:
139
139
  console.log(d.udid);')
140
140
  xcrun simctl boot "$udid"
141
141
  xcrun simctl bootstatus "$udid" -b
142
- echo "$udid" > ~/.devbox/sim
143
142
  xcrun simctl list devices booted
144
143
 
144
+ # Values that the commands in `expo-devbox help` use, set in every SSH
145
+ # session. Prebuild names the scheme, the app and its executable alike.
146
+ workspace=$(ls -d ios/*.xcworkspace | head -1)
147
+ scheme=$(basename "$workspace" .xcworkspace)
148
+ bundle_id=$(npx expo config --type public --json | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).ios?.bundleIdentifier ?? ""')
149
+ derived="$HOME/.devbox/DerivedData"
150
+ cat >> ~/.ssh/environment <<EOF
151
+ DEVBOX_SIMULATOR=$udid
152
+ DEVBOX_WORKSPACE=$workspace
153
+ DEVBOX_SCHEME=$scheme
154
+ DEVBOX_DERIVED_DATA=$derived
155
+ DEVBOX_APP=$derived/Build/Products/Debug-iphonesimulator/$scheme.app
156
+ DEVBOX_BUNDLE_ID=$bundle_id
157
+ DEVBOX_EXECUTABLE=$scheme
158
+ DEVBOX_LOG_PREDICATE=process == "$scheme" AND (senderImagePath CONTAINS "/$scheme.app/" OR (senderImagePath ENDSWITH "/Foundation" AND NOT subsystem CONTAINS "."))
159
+ EOF
160
+ grep '^DEVBOX_' ~/.ssh/environment
161
+
145
162
  - name: Keep the worker up
146
163
  run: |
147
164
  dir="$HOME/.devbox"
@@ -150,13 +167,11 @@ jobs:
150
167
  env -u CI npx expo start --localhost > "$dir/metro.log" 2>&1 < /dev/null &
151
168
  }
152
169
  start_metro
153
- # The same xcodebuild arguments as `expo-devbox build`, so its first build is incremental.
154
- workspace=$(ls -d ios/*.xcworkspace | head -1)
155
- scheme=$(node -p 'try { require("./devbox.json").scheme || "" } catch { "" }')
156
- xcodebuild -workspace "$workspace" -scheme "${scheme:-$(basename "$workspace" .xcworkspace)}" \
157
- -configuration Debug -sdk iphonesimulator -destination "platform=iOS Simulator,id=$(cat "$dir/sim")" \
158
- -derivedDataPath "$dir/DerivedData" build > "$dir/xcodebuild.log" 2>&1 \
159
- || echo "The warm-up build failed. Run expo-devbox build to see why."
170
+ # The build command from `expo-devbox help`, so the first build there is incremental.
171
+ devbox() { grep "^$1=" ~/.ssh/environment | cut -d= -f2-; }
172
+ xcodebuild -quiet -workspace "$(devbox DEVBOX_WORKSPACE)" -scheme "$(devbox DEVBOX_SCHEME)" -configuration Debug \
173
+ -destination "id=$(devbox DEVBOX_SIMULATOR)" -derivedDataPath "$(devbox DEVBOX_DERIVED_DATA)" build \
174
+ > "$dir/warm-up-build.log" 2>&1 || echo "The warm-up build failed. Run the build command from expo-devbox help to see why."
160
175
  touch "$dir/ready"
161
176
  echo "Devbox ready at ~/project."
162
177
 
@@ -1,181 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Runs on the devbox worker, in the project directory. Builds the iOS app with
4
- * Xcode, installs it on the devbox simulator, launches it, and prints its logs.
5
- * Exits non-zero when the build fails, the app is not running after launch, or
6
- * the checks in devbox.json fail. `expo-devbox build` copies this file to the
7
- * worker, so it must not import anything but Bun and Node built-ins.
8
- *
9
- * worker-build.ts incremental build
10
- * worker-build.ts --pods run `pod install` first
11
- * worker-build.ts --prebuild install dependencies, run `expo prebuild` and `pod install` first
12
- */
13
- import { $ } from 'bun';
14
- import { appendFileSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
- import { homedir } from 'node:os';
16
- import { basename, join } from 'node:path';
17
-
18
- type Config = { waitFor?: string; failOn?: string; waitSeconds?: number; scheme?: string };
19
-
20
- const state = join(homedir(), '.devbox');
21
- const config: Config = existsSync('devbox.json') ? JSON.parse(readFileSync('devbox.json', 'utf8')) : {};
22
- const waitSeconds = config.waitSeconds ?? (config.waitFor ? 60 : 8);
23
- const prepareLog = join(state, 'prepare.log');
24
-
25
- /** Runs a setup command with its output in prepare.log, which is printed only if the command fails. */
26
- async function prepare(label: string, command: () => Promise<{ exitCode: number; stdout: Buffer; stderr: Buffer }>) {
27
- console.log(`Running: ${label}`);
28
- const result = await command();
29
- appendFileSync(prepareLog, `$ ${label}\n${result.stdout}${result.stderr}`);
30
- if (result.exitCode !== 0) {
31
- console.log(readFileSync(prepareLog, 'utf8').trimEnd().split('\n').slice(-30).join('\n'));
32
- console.log(`FAILED: ${label}. Full log: ${prepareLog}`);
33
- process.exit(1);
34
- }
35
- }
36
- const podInstall = () => prepare('pod install', () => $`pod install`.cwd('ios').quiet().nothrow());
37
-
38
- writeFileSync(prepareLog, '');
39
- const flag = process.argv[2];
40
- if (flag === '--prebuild') {
41
- const manager = existsSync('bun.lock') || existsSync('bun.lockb') ? 'bun'
42
- : existsSync('pnpm-lock.yaml') ? 'pnpm' : existsSync('yarn.lock') ? 'yarn' : 'npm';
43
- await prepare(`${manager} install`, () => $`${manager} install`.quiet().nothrow());
44
- if (existsSync(join(state, 'cng'))) {
45
- await prepare('expo prebuild', () => $`npx expo prebuild --platform ios --no-install`.quiet().nothrow());
46
- }
47
- await podInstall();
48
- } else if (flag === '--pods') {
49
- await podInstall();
50
- } else if (flag) {
51
- console.log(`Unknown option ${flag}. Use --pods or --prebuild.`);
52
- process.exit(1);
53
- }
54
-
55
- const workspace = readdirSync('ios').find(name => name.endsWith('.xcworkspace'));
56
- if (!workspace) {
57
- console.log('No .xcworkspace in ios/. Run the build again with --prebuild.');
58
- process.exit(1);
59
- }
60
- const scheme = config.scheme || basename(workspace, '.xcworkspace');
61
- const udid = readFileSync(join(state, 'sim'), 'utf8').trim();
62
- const derived = join(state, 'DerivedData');
63
- const buildLog = join(state, 'xcodebuild.log');
64
- const screenshot = join(state, 'screenshot.png');
65
- rmSync(screenshot, { force: true });
66
-
67
- const start = Date.now();
68
- const seconds = () => Math.round((Date.now() - start) / 1000);
69
- const build = await $`xcodebuild -workspace ${`ios/${workspace}`} -scheme ${scheme} -configuration Debug -sdk iphonesimulator \
70
- -destination ${`platform=iOS Simulator,id=${udid}`} -derivedDataPath ${derived} build &> ${buildLog}`.nothrow();
71
- if (build.exitCode !== 0) {
72
- const lines = readFileSync(buildLog, 'utf8').split('\n');
73
- const errors = [...new Set(lines.filter(line => /(error|fatal error):/.test(line)))].slice(0, 40);
74
- console.log((errors.length ? errors : lines.slice(-30)).join('\n'));
75
- console.log(`BUILD FAILED after ${seconds()}s. Full log: ${buildLog}`);
76
- process.exit(1);
77
- }
78
- console.log(`BUILD SUCCEEDED in ${seconds()}s`);
79
-
80
- const metroUp = await fetch('http://localhost:8081/status').then(r => r.ok, () => false);
81
- if (!metroUp) {
82
- console.log('Metro is not running. The worker restarts it within a minute; run the build again then.');
83
- process.exit(1);
84
- }
85
-
86
- const products = join(derived, 'Build/Products/Debug-iphonesimulator');
87
- const app = join(products, readdirSync(products).find(name => name.endsWith('.app'))!);
88
- const plist = join(app, 'Info.plist');
89
- const plistValue = async (key: string) => (await $`/usr/libexec/PlistBuddy -c ${`Print :${key}`} ${plist}`.text()).trim();
90
- const bundleId = await plistValue('CFBundleIdentifier');
91
- const executable = await plistValue('CFBundleExecutable');
92
- const metroLog = join(state, 'metro.log');
93
- const nativeLog = join(state, 'native.log');
94
- const metroStart = readFileSync(metroLog).length;
95
-
96
- await $`xcrun simctl install ${udid} ${app}`;
97
- // Messages logged by code inside the app bundle, plus NSLog (sent from
98
- // Foundation, with no subsystem); not the system libraries' own logging.
99
- // `subsystem == ""` never matches, so "no subsystem" is written as no dot.
100
- const predicate = `process == "${executable}" AND (senderImagePath CONTAINS "/${executable}.app/" `
101
- + `OR (senderImagePath ENDSWITH "/Foundation" AND NOT subsystem CONTAINS "."))`;
102
- const logStream = Bun.spawn(['xcrun', 'simctl', 'spawn', udid, 'log', 'stream', '--style', 'compact', '--predicate', predicate],
103
- { stdout: Bun.file(nativeLog), stderr: 'ignore' });
104
- await $`xcrun simctl launch --terminate-running-process ${udid} ${bundleId}`.quiet();
105
-
106
- const appOutput = () => readFileSync(metroLog).subarray(metroStart).toString()
107
- .split('\n').filter(line => /^ (LOG|WARN|ERROR) /.test(line)).join('\n');
108
- // Simulator apps run as processes of the host.
109
- const appRunning = async () => (await $`pgrep -f ${`/${executable}.app/${executable}`}`.quiet().nothrow()).exitCode === 0;
110
- const matches = (pattern: string | undefined) => !!pattern && new RegExp(pattern, 'm').test(appOutput());
111
-
112
- for (let i = 0; i < waitSeconds; i += 1) {
113
- if (matches(config.waitFor)) break;
114
- if (config.waitFor && i > 3 && !(await appRunning())) break;
115
- await Bun.sleep(1000);
116
- }
117
- if ((await $`xcrun simctl io ${udid} screenshot ${screenshot}`.quiet().nothrow()).exitCode !== 0) {
118
- console.log('Could not take a screenshot.');
119
- }
120
- logStream.kill();
121
- await logStream.exited;
122
-
123
- console.log('App console output from this launch:');
124
- console.log(appOutput() || '(none)');
125
- console.log('Native log from this launch (system libraries excluded):');
126
- const native = readFileSync(nativeLog, 'utf8').split('\n')
127
- .filter(line => line && !/^(Filtering the log data|Timestamp |getpwuid_r |Child process terminated)/.test(line));
128
- console.log(native.slice(-40).join('\n') || '(none)');
129
-
130
- let status = 0;
131
- if (!(await appRunning())) {
132
- console.log('The app is not running. It may have crashed at launch.');
133
- await printCrashReport(executable, statSync(plist).mtimeMs);
134
- status = 1;
135
- }
136
- if (config.waitFor && !matches(config.waitFor)) {
137
- console.log(`The app did not log a line matching waitFor (${config.waitFor}).`);
138
- status = 1;
139
- }
140
- if (matches(config.failOn)) {
141
- console.log(`The app logged a line matching failOn (${config.failOn}).`);
142
- status = 1;
143
- }
144
- process.exit(status);
145
-
146
- /**
147
- * Prints the exception and the crashing thread's frames from the newest crash
148
- * report written since the build. macOS writes the report a few seconds after
149
- * the crash, so this waits for it.
150
- */
151
- async function printCrashReport(executable: string, since: number) {
152
- const dir = join(homedir(), 'Library/Logs/DiagnosticReports');
153
- const newest = () => (existsSync(dir) ? readdirSync(dir) : [])
154
- .filter(name => name.startsWith(executable) && name.endsWith('.ips'))
155
- .map(name => ({ path: join(dir, name), time: statSync(join(dir, name)).mtimeMs }))
156
- .filter(report => report.time > since)
157
- .sort((a, b) => b.time - a.time)[0];
158
- let report = newest();
159
- for (let i = 0; !report && i < 10; i += 1) {
160
- await Bun.sleep(1000);
161
- report = newest();
162
- }
163
- if (!report) {
164
- console.log('No crash report appeared within 10 seconds.');
165
- return;
166
- }
167
- console.log(`Crash report ${report.path}:`);
168
- // An .ips file is a JSON header line followed by the JSON report.
169
- const text = readFileSync(report.path, 'utf8');
170
- try {
171
- const crash = JSON.parse(text.slice(text.indexOf('\n') + 1));
172
- console.log(`${crash.exception?.type} (${crash.exception?.signal}), ${crash.termination?.indicator ?? ''}`);
173
- const thread = crash.threads?.find((t: { triggered?: boolean }) => t.triggered);
174
- for (const frame of thread?.frames?.slice(0, 15) ?? []) {
175
- const where = frame.sourceFile ? ` (${frame.sourceFile}:${frame.sourceLine})` : '';
176
- console.log(` ${crash.usedImages?.[frame.imageIndex]?.name ?? '?'} ${frame.symbol ?? '?'}${where}`);
177
- }
178
- } catch {
179
- console.log(text.split('\n').slice(0, 40).join('\n'));
180
- }
181
- }