expo-devbox 0.1.1 → 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/README.md CHANGED
@@ -1,39 +1,24 @@
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
- **iOS only for now.** Android support, on an EAS Linux worker with the Android Emulator, will be added to this tool shortly.
8
-
9
- ```
10
- $ bunx expo-devbox build
11
- BUILD SUCCEEDED in 5s
12
- App console output from this launch:
13
- LOG [native-crypto] PASS sha384("abc"): cb00753f…
14
- LOG [native-crypto] DONE 7/7 passed
15
- Native log from this launch (system libraries excluded):
16
- 2026-10-24 16:59:04.278 Df myapp[6954:97f5] (Foundation) [native-crypto] module created
17
- Screenshot copied to .devbox/screenshot.png
18
- ```
19
-
20
- > [!NOTE]
21
- > 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: syncing your files, incremental builds, launching the app and checking its logs, crash reports, `devbox.json`, and the instructions for coding agents. When workflow SSH ships, `expo-devbox` will use it.
22
-
23
7
  ## Set it up with your coding agent
24
8
 
25
9
  Paste this into your coding agent, in your Expo app:
26
10
 
27
11
  ```text
28
- Set up expo-devbox (https://www.npmjs.com/package/expo-devbox) in this Expo app, so that you build and test native iOS code on an EAS macOS worker instead of this machine.
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.
29
13
 
30
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`.
31
15
  2. Run `bunx expo-devbox init` in the app's root directory.
32
- 3. Read the "Native iOS builds on EAS (expo-devbox)" section that init added to AGENTS.md or CLAUDE.md, and follow it from now on.
33
- 4. If the app logs a line when it is ready or when a self-test passes or fails, set `waitFor` and `failOn` in devbox.json to regular expressions for those lines.
34
- 5. Ask me before you run `bunx expo-devbox start`: a session bills EAS macOS minutes while it runs. Then run `bunx expo-devbox build`, tell me the result, and run `bunx expo-devbox stop` when we are done.
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.
35
18
  ```
36
19
 
20
+ **iOS only for now.** Android support, on an EAS Linux worker with the Android Emulator, will be added to this tool shortly.
21
+
37
22
  ## Requirements
38
23
 
39
24
  - An Expo app linked to an EAS project (`eas init`), on a plan with macOS workflow minutes. A session holds one macOS concurrency slot and is billed for as long as it runs. Check your plan's price and job time limits.
@@ -48,98 +33,144 @@ In the root of your app:
48
33
  bunx expo-devbox init
49
34
  ```
50
35
 
51
- `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
53
+
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.
55
+
56
+ For example:
52
57
 
53
- - writes the workflow to `.eas/workflows/ios-devbox.yml`,
54
- - writes `devbox.json`,
55
- - adds `.devbox/` to `.gitignore`,
56
- - adds instructions for coding agents to `AGENTS.md` or `CLAUDE.md`. If neither file exists, it prints them instead; see [Coding agents](#coding-agents).
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
+ ```
57
66
 
58
- Then:
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.
59
68
 
60
69
  ```sh
61
- bunx expo-devbox start # start a session: 60 minutes at most, ends after 15 idle minutes
62
- bunx expo-devbox build # sync, build, launch, check
63
- 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
64
71
  ```
65
72
 
66
- ## Commands
73
+ **Install and launch** the app on the simulator:
67
74
 
68
- | Command | What it does |
69
- | --- | --- |
70
- | `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. |
71
- | `build` | Syncs your files, builds, launches the app, prints its console and native logs, and copies the screenshot to `.devbox/screenshot.png`. |
72
- | `build --pods` | Same, after adding or removing native source files or editing a podspec. |
73
- | `build --prebuild` | Same, after changing `package.json`, the app config or a config plugin. |
74
- | `ssh [command]` | Runs a command in the project directory on the worker, or opens a shell. |
75
- | `logs [pattern]` | Searches the app's console output from Metro. |
76
- | `sync` | Copies your files to the worker without building. |
77
- | `attach [run-id]` | Reconnects to a running session. `build` reconnects on its own. |
78
- | `joblog` | Prints the workflow job's log. |
79
- | `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:
80
+
81
+ ```sh
82
+ tail -n 50 ~/.devbox/metro.log
83
+ ```
84
+
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
+ ```
90
+
91
+ **Check that the app is still running**, and if it crashed, read the exception and crashing frames from the newest crash report:
92
+
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
+ ```
80
110
 
81
- `build` exits non-zero, and says why, when:
111
+ **After changing `package.json`, the app config or a config plugin.** Use your package manager, and skip prebuild if you commit `ios/`:
82
112
 
83
- - the build fails: it prints the compiler errors with file and line,
84
- - the app is not running after launch: it prints the exception and the crashing frames,
85
- - the app does not log a line matching `waitFor`, or logs one matching `failOn`.
113
+ ```sh
114
+ bun install && npx expo prebuild --platform ios --no-install && cd ios && pod install
115
+ ```
86
116
 
87
- ## devbox.json
117
+ ### The variables
88
118
 
89
- | Key | Meaning |
119
+ | Variable | Value |
90
120
  | --- | --- |
91
- | `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. |
92
- | `failOn` | A regular expression for a line that means failure. `build` exits non-zero if the app logs it. |
93
- | `waitSeconds` | How long to wait for `waitFor`. The default is 60. |
94
- | `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. |
95
126
 
96
127
  ## Coding agents
97
128
 
98
- `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:
99
130
 
100
131
  ```markdown
101
132
  ## Native iOS builds on EAS (expo-devbox)
102
133
 
103
- 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.
104
135
 
105
- 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.
106
- 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.
107
- 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`.
108
- 4. `bunx expo-devbox ssh '<command>'` runs a command in the project directory on the worker, for example `xcrun simctl list devices booted`.
109
- 5. When you are done, run `bunx expo-devbox stop`.
110
- ```
136
+ When the user asks for it:
111
137
 
112
- 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
+ ```
113
143
 
114
- 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.
115
145
 
116
146
  ## How it works
117
147
 
118
148
  The workflow runs one custom job on `macos-large` in the `development` environment:
119
149
 
120
- 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.
121
- 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.
122
- 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.
123
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.
124
154
 
125
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.
126
156
 
127
- Steps 1 and 2, and the idle shutdown in step 4, exist because EAS Workflows does not offer SSH yet. Workflow SSH is coming soon and will replace them; see the note at the top.
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.
128
159
 
129
160
  ## Limits
130
161
 
131
162
  - iOS only. Android support will be added shortly.
132
163
  - One app per project root. Monorepos with dependencies outside the app directory are not supported.
133
- - `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.
134
165
  - Private npm packages need their token as an EAS environment variable in the `development` environment.
135
166
 
136
167
  ## Turn off the intro
137
168
 
138
- `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.
139
170
 
140
171
  ## Developing expo-devbox
141
172
 
142
- 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.
143
174
 
144
175
  ```sh
145
176
  bun install
@@ -150,7 +181,7 @@ bun link && bun install -g . # put this checkout on your PATH as expo-devbox
150
181
 
151
182
  ### The example app
152
183
 
153
- [`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.
154
185
 
155
186
  The example does not depend on `expo-devbox`. Run the linked CLI from the example's directory:
156
187
 
@@ -158,23 +189,21 @@ The example does not depend on `expo-devbox`. Run the linked CLI from the exampl
158
189
  cd example
159
190
  bun install
160
191
  expo-devbox start
161
- expo-devbox build
162
- expo-devbox stop
163
192
  ```
164
193
 
165
194
  To use your own EAS account, delete `owner` and `extra.eas` from `example/app.json` and run `eas init` in `example/`.
166
195
 
167
- 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:
168
197
 
169
198
  | Change | Expected result |
170
199
  | --- | --- |
171
- | None | exit 0, `DONE 7/7 passed`, the `module created` line in the native log |
172
- | A wrong expected value in `App.tsx` | exit 1, `matching failOn` |
173
- | A Swift type error | exit 1, the error with file and line |
174
- | `fatalError()` in the module's `OnCreate` | exit 1, the crash report frames |
175
- | `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 **` |
176
205
  | `.env.local` present, then `sync` | the file is not on the worker |
177
- | `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 |
178
207
  | `stop` | `The devbox session is canceled.` |
179
208
 
180
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.1",
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
- }