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 +109 -80
- package/package.json +1 -1
- package/src/cli.ts +46 -16
- package/src/init.ts +10 -14
- package/src/session.ts +42 -59
- package/templates/ios-devbox.yml +23 -8
- package/src/worker-build.ts +0 -181
package/README.md
CHANGED
|
@@ -1,39 +1,24 @@
|
|
|
1
1
|
# expo-devbox
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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,
|
|
33
|
-
4.
|
|
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`
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
**Install and launch** the app on the simulator:
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
113
|
+
```sh
|
|
114
|
+
bun install && npx expo prebuild --platform ios --no-install && cd ios && pod install
|
|
115
|
+
```
|
|
86
116
|
|
|
87
|
-
|
|
117
|
+
### The variables
|
|
88
118
|
|
|
89
|
-
|
|
|
119
|
+
| Variable | Value |
|
|
90
120
|
| --- | --- |
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`, `
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
-
- `
|
|
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
|
|
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
|
|
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
|
|
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 |
|
|
172
|
-
| A
|
|
173
|
-
|
|
|
174
|
-
|
|
|
175
|
-
|
|
|
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 `
|
|
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
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,
|
|
4
|
+
import { attach, joblog, shell, start, stop, sync, UserError } from './session';
|
|
5
5
|
import { status } from './status';
|
|
6
6
|
|
|
7
|
-
const HELP = `
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
66
|
-
bunx expo-devbox
|
|
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
|
|
4
|
-
* stop it. Run from the
|
|
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,
|
|
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}.
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
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
|
|
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 ===
|
|
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
|
|
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}
|
|
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(
|
|
187
|
-
status('done', { msg: '
|
|
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
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
package/templates/ios-devbox.yml
CHANGED
|
@@ -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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
-
|
|
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
|
|
package/src/worker-build.ts
DELETED
|
@@ -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
|
-
}
|