expo-devbox 0.1.1 → 0.2.1

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,145 +1,185 @@
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 with your app prebuilt, its pods installed, an iOS Simulator booted, Metro running and a first build done. Then you copy files over with `sync`, run commands with `ssh`, and copy results back with `pull`.
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
 
37
- ## Requirements
20
+ **iOS only for now.** Android support, on an EAS Linux worker with the Android Emulator, will be added to this tool shortly.
21
+
22
+ <details>
23
+ <summary><strong>Requirements</strong></summary>
38
24
 
39
25
  - 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.
40
26
  - [Bun](https://bun.sh) 1.3 or later, and `eas-cli`, logged in.
41
27
  - `cloudflared`: `brew install cloudflared`.
42
28
 
43
- ## Set up
29
+ </details>
30
+
31
+ ## Quick start
44
32
 
45
33
  In the root of your app:
46
34
 
47
35
  ```sh
48
- bunx expo-devbox init
36
+ bunx expo-devbox init # add the workflow, a .gitignore line and the agent instructions
37
+ bunx expo-devbox start # start a session; returns when the worker is ready (about 4 minutes)
38
+ bunx expo-devbox sync # after each change: copy your files to the worker
39
+ bunx expo-devbox ssh '<command>' # build, launch and check the app: see "On the worker"
40
+ bunx expo-devbox stop # end the session
41
+ ```
42
+
43
+ | Command | What it does |
44
+ | --- | --- |
45
+ | `init [--force] [--no-agents]` | Writes `.eas/workflows/ios-devbox.yml`, adds `.devbox/` (session state) to `.gitignore`, and adds the [agent instructions](#coding-agents) to `AGENTS.md` or `CLAUDE.md`. |
46
+ | `start [minutes] [idle-minutes]` | Starts a new session and waits until its worker is ready. It ends after `minutes` (default 60) or `idle-minutes` with no SSH connection (default 15). |
47
+ | `sync` | Copies your files to the worker: what git would track, so `.gitignore` applies. |
48
+ | `ssh ['command']` | Runs a command in the app's directory on the worker, or opens a shell. |
49
+ | `pull <path> [local-path]` | Copies a file from the worker. A relative path is in the app's directory there. |
50
+ | `sessions`, `attach [id]` | Lists this directory's sessions; reconnects to one and makes it current. |
51
+ | `joblog` | Prints the session's workflow job log. |
52
+ | `stop [--all]` | Ends the session, or every session in this directory, and waits until EAS confirms it. |
53
+
54
+ `expo-devbox help` prints all of this and the steps below.
55
+
56
+ <details>
57
+ <summary id="sessions"><strong>Several sessions</strong></summary>
58
+
59
+ Sessions work like Claude Code's. Each `start` begins a new session and makes it the current one, and the other commands use the current session, so with one session you never name it. To use another one, add `--session <id>` (or `-s <id>`) before the command's arguments, or set `EXPO_DEVBOX_SESSION=<id>`. Any unique prefix of the ID works:
60
+
61
+ ```
62
+ $ bunx expo-devbox sessions
63
+ * 01a170ec-7181 IN_PROGRESS 10/24/2026, 7:38:26 PM
64
+ 01a170e8-b2c6 IN_PROGRESS 10/24/2026, 7:34:20 PM
49
65
  ```
50
66
 
51
- `init` does the following:
67
+ Several sessions in one directory help when two agents share a checkout. Each session is its own worker, holds its own macOS concurrency slot and is billed separately. Separate directories, such as git worktrees, keep separate sessions too.
68
+
69
+ </details>
52
70
 
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).
71
+ ## On the worker
57
72
 
58
- Then:
73
+ After each change, run `expo-devbox sync`, then run these with `expo-devbox ssh '<command>'`. The `DEVBOX_*` variables are set in every SSH session. After the worker's first build, an incremental build takes a few seconds.
59
74
 
60
75
  ```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
76
+ # Build. Prints errors with file and line, then ** BUILD SUCCEEDED ** or ** BUILD FAILED **, and exits
77
+ # non-zero on failure. The full log is ~/.devbox/build.log.
78
+ 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
79
+
80
+ # Install and launch the app on the simulator.
81
+ xcrun simctl install booted "$DEVBOX_APP" && xcrun simctl launch --terminate-running-process booted "$DEVBOX_BUNDLE_ID"
82
+
83
+ # JavaScript console output and errors (Metro).
84
+ tail -n 50 ~/.devbox/metro.log
85
+
86
+ # Native log: NSLog, and os_log or Logger output from the app's own code, from the last minute.
87
+ xcrun simctl spawn booted log show --last 1m --style compact --predicate "$DEVBOX_LOG_PREDICATE" 2>/dev/null
88
+
89
+ # Check that the app is still running; if it crashed, the newest crash report's exception and frames.
90
+ pgrep -lf "$DEVBOX_EXECUTABLE.app/$DEVBOX_EXECUTABLE" || echo "The app is not running."
91
+ 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}]}"
92
+
93
+ # Screenshot. Then, on your machine: expo-devbox pull .devbox/screenshot.png .devbox/
94
+ mkdir -p .devbox && xcrun simctl io booted screenshot .devbox/screenshot.png
95
+
96
+ # After adding or removing native files, or editing a podspec.
97
+ cd ios && pod install
98
+
99
+ # After changing package.json, the app config or a config plugin. Use your package manager;
100
+ # skip prebuild if you commit ios/.
101
+ bun install && npx expo prebuild --platform ios --no-install && cd ios && pod install
64
102
  ```
65
103
 
66
- ## Commands
104
+ <details>
105
+ <summary id="variables"><strong>The <code>DEVBOX_*</code> variables</strong></summary>
67
106
 
68
- | Command | What it does |
107
+ | Variable | Value |
69
108
  | --- | --- |
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. |
80
-
81
- `build` exits non-zero, and says why, when:
82
-
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`.
86
-
87
- ## devbox.json
88
-
89
- | Key | Meaning |
90
- | --- | --- |
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`. |
109
+ | `DEVBOX_WORKSPACE`, `DEVBOX_SCHEME` | The Xcode workspace (`ios/<name>.xcworkspace`) and scheme. |
110
+ | `DEVBOX_SIMULATOR` | The booted simulator's UDID. |
111
+ | `DEVBOX_DERIVED_DATA` | The DerivedData folder of the worker's first build. Build with it to keep builds incremental. |
112
+ | `DEVBOX_APP`, `DEVBOX_BUNDLE_ID`, `DEVBOX_EXECUTABLE` | The built `.app`, its bundle identifier, and its executable's name. |
113
+ | `DEVBOX_LOG_PREDICATE` | A `log` predicate for messages from the app's own code and `NSLog`, without system libraries' logging. Swift's `print` writes to stdout and does not appear in the log. |
114
+
115
+ The 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.
116
+
117
+ </details>
95
118
 
96
- ## Coding agents
119
+ ## Additional information
97
120
 
98
- `init` adds the following to `AGENTS.md` or `CLAUDE.md`. Copy it into your agent's instructions if you use another file:
121
+ <details>
122
+ <summary id="coding-agents"><strong>Instructions for coding agents</strong></summary>
123
+
124
+ `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
125
 
100
126
  ```markdown
101
127
  ## Native iOS builds on EAS (expo-devbox)
102
128
 
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.
129
+ `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.
130
+
131
+ When the user asks for it:
104
132
 
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`.
133
+ 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.
134
+ 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.
135
+ 3. After each change, run `bunx expo-devbox sync`, then the commands from the help with `bunx expo-devbox ssh '<command>'`.
136
+ 4. Commands use the session you started last in this directory. If another agent works in the same directory, pass your session's ID, which `start` prints, to every command: `bunx expo-devbox ssh --session <id> '<command>'`.
137
+ 5. When the user is done with the devbox, run `bunx expo-devbox stop`.
110
138
  ```
111
139
 
112
- To check results without reading pixels, have your app log a line when its checks pass or fail, and set `waitFor` and `failOn`.
140
+ 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.
113
141
 
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.
142
+ </details>
115
143
 
116
- ## How it works
144
+ <details>
145
+ <summary id="how-it-works"><strong>How it works</strong></summary>
117
146
 
118
147
  The workflow runs one custom job on `macos-large` in the `development` environment:
119
148
 
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.
149
+ 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.
150
+ 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`, `ssh` and `pull` reconnect. A command is never run twice: if the connection drops while it runs, `ssh` exits 255.
151
+ 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
152
  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
153
 
125
154
  `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
155
 
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.
156
+ > [!NOTE]
157
+ > 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 (steps 1 and 2, and the idle shutdown in step 4). The other half stays: preparing the worker, syncing your files safely, the variables and commands for building and running the app, and the instructions for coding agents. When workflow SSH ships, `expo-devbox` will use it.
158
+
159
+ </details>
128
160
 
129
- ## Limits
161
+ <details>
162
+ <summary><strong>Known limitations</strong></summary>
130
163
 
131
164
  - iOS only. Android support will be added shortly.
132
165
  - 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.
134
166
  - Private npm packages need their token as an EAS environment variable in the `development` environment.
135
167
 
136
- ## Turn off the intro
168
+ </details>
169
+
170
+ <details>
171
+ <summary><strong>The intro animation</strong></summary>
172
+
173
+ `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.
137
174
 
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.
175
+ </details>
139
176
 
140
- ## Developing expo-devbox
177
+ ## Contributing
141
178
 
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.
179
+ <details>
180
+ <summary><strong>Develop, test, and check a release</strong></summary>
181
+
182
+ 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
183
 
144
184
  ```sh
145
185
  bun install
@@ -148,33 +188,26 @@ bun test
148
188
  bun link && bun install -g . # put this checkout on your PATH as expo-devbox
149
189
  ```
150
190
 
151
- ### The example app
152
-
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`.
154
-
155
- The example does not depend on `expo-devbox`. Run the linked CLI from the example's directory:
191
+ [`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), that 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), logs a `[native-crypto] PASS` or `FAIL` line for each check, then `[native-crypto] DONE`, and `NSLog`s `[native-crypto] module created` from Swift.
156
192
 
157
- ```sh
158
- cd example
159
- bun install
160
- expo-devbox start
161
- expo-devbox build
162
- expo-devbox stop
163
- ```
193
+ The example does not depend on `expo-devbox`; run the linked CLI from `example/` (`bun install`, then `expo-devbox start`). To use your own EAS account, delete `owner` and `extra.eas` from `example/app.json` and run `eas init` in `example/`. After you change `templates/ios-devbox.yml`, run `expo-devbox init --force --no-agents` in `example/` to update its workflow.
164
194
 
165
- To use your own EAS account, delete `owner` and `extra.eas` from `example/app.json` and run `eas init` in `example/`.
166
-
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.
195
+ 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
196
 
169
197
  | Change | Expected result |
170
198
  | --- | --- |
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 |
199
+ | None: sync, build, install and launch | `** BUILD SUCCEEDED **`; Metro log ends with `DONE 7/7 passed`; the native log has `module created` |
200
+ | A Swift type error | the build prints the error with file and line, `** BUILD FAILED **`, and exits non-zero |
201
+ | `fatalError()` in the module's `OnCreate` | `The app is not running.`; the crash report frames point at `NativeCryptoModule.swift` |
202
+ | A wrong expected value in `App.tsx` | Metro log has a `FAIL` line |
203
+ | Prebuild and pod install, then build | `** BUILD SUCCEEDED **` |
176
204
  | `.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 |
178
- | `stop` | `The devbox session is canceled.` |
205
+ | `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 |
206
+ | `start` twice, then `sessions`, `ssh --session <first id> 'true'` and `stop --all` | two sessions listed, the newer one current; the command runs on the first; both sessions end |
207
+ | `stop` | `The devbox session <id> is canceled.` |
208
+
209
+ </details>
210
+
211
+ ## License
179
212
 
180
- After you change `templates/ios-devbox.yml`, run `expo-devbox init --force --no-agents` in `example/` to update its workflow.
213
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-devbox",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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/args.ts ADDED
@@ -0,0 +1,49 @@
1
+ /** Command-line parsing, kept apart from the commands so it can be tested. */
2
+ export type Parsed = { command?: string; args: string[]; session?: string };
3
+
4
+ /**
5
+ * Splits argv into the command, its arguments and a --session value.
6
+ * `--session <id>`, `--session=<id>` and `-s <id>` are read before the command
7
+ * and before the command's first argument, so a remote command passed to `ssh`
8
+ * is never parsed: `expo-devbox ssh -s 01a1 'ls --session'`.
9
+ */
10
+ export function parseArgs(argv: string[]): Parsed {
11
+ let session: string | undefined;
12
+ let command: string | undefined;
13
+ const args: string[] = [];
14
+ let i = 0;
15
+ const readFlags = () => {
16
+ while (i < argv.length) {
17
+ const arg = argv[i]!;
18
+ if (arg === '--session' || arg === '-s') {
19
+ session = argv[i + 1];
20
+ i += 2;
21
+ } else if (arg.startsWith('--session=')) {
22
+ session = arg.slice('--session='.length);
23
+ i += 1;
24
+ } else {
25
+ return;
26
+ }
27
+ }
28
+ };
29
+ readFlags();
30
+ if (i < argv.length) command = argv[i++];
31
+ readFlags();
32
+ args.push(...argv.slice(i));
33
+ return { command, args, ...(session ? { session } : {}) };
34
+ }
35
+
36
+ /** Finds the one session ID that starts with the prefix. */
37
+ export function matchSession(ids: string[], prefix: string): string {
38
+ const matches = ids.filter(id => id.startsWith(prefix));
39
+ if (matches.length === 1) return matches[0]!;
40
+ if (matches.length === 0) {
41
+ throw new Error(`No devbox session in this directory starts with "${prefix}". See: expo-devbox sessions`);
42
+ }
43
+ throw new Error(`"${prefix}" matches ${matches.length} sessions: ${matches.map(shortId).join(', ')}. Use more of the ID.`);
44
+ }
45
+
46
+ /** The part of a session (workflow run) ID that is shown: unique per millisecond of start time. */
47
+ export function shortId(id: string): string {
48
+ return id.slice(0, 13);
49
+ }
package/src/cli.ts CHANGED
@@ -1,32 +1,73 @@
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 { parseArgs } from './args';
5
+ import { attach, joblog, pull, selectSession, sessions, shell, start, stop, sync, UserError } from './session';
5
6
  import { status } from './status';
6
7
 
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.
8
+ const HELP = `A macOS EAS worker for building and testing an Expo app's native iOS code,
9
+ for you or a coding agent. Run these in the app's root directory.
9
10
 
10
- Usage: expo-devbox <command>
11
+ Commands:
12
+ init [--force] [--no-agents] add the devbox workflow to this app
13
+ start [minutes] [idle-minutes] start a new session and wait until its worker is ready (about 4 minutes).
14
+ It ends after [minutes] (default 60) or after [idle-minutes] without
15
+ an SSH connection (default 15). EAS macOS minutes are billed until then.
16
+ sync copy your files to the worker (what git would track; .gitignore applies)
17
+ ssh ['command'] run a command in the app's directory on the worker, or open a shell
18
+ pull <path> [local-path] copy a file from the worker; a relative path is in the app's directory
19
+ sessions list this directory's sessions
20
+ attach [id] reconnect to a session and make it the current one
21
+ joblog print the session's workflow job log
22
+ stop [--all] end the session (or all of them) and wait until EAS confirms it ended
11
23
 
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
24
+ Sessions:
25
+ Each start begins a new session and makes it the current one. The other commands use the current
26
+ session. To use another, add --session <id> (or -s <id>) before the command's arguments, or set
27
+ EXPO_DEVBOX_SESSION=<id>. Any unique prefix of an ID works; expo-devbox sessions lists them.
28
+
29
+ On the worker:
30
+ After each change, run expo-devbox sync, then the commands below with expo-devbox ssh '<command>'.
31
+ The DEVBOX_* variables are set in every SSH session.
32
+
33
+ Build. Prints errors with file and line, then ** BUILD SUCCEEDED ** or ** BUILD FAILED **.
34
+ The full log is ~/.devbox/build.log.
35
+ 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
36
+
37
+ Install and launch the app on the simulator:
38
+ xcrun simctl install booted "$DEVBOX_APP" && xcrun simctl launch --terminate-running-process booted "$DEVBOX_BUNDLE_ID"
39
+
40
+ JavaScript console output and errors (Metro):
41
+ tail -n 50 ~/.devbox/metro.log
42
+
43
+ Native log (NSLog, and os_log or Logger output from the app's own code; last minute):
44
+ xcrun simctl spawn booted log show --last 1m --style compact --predicate "$DEVBOX_LOG_PREDICATE" 2>/dev/null
45
+
46
+ Check that the app is still running:
47
+ pgrep -lf "$DEVBOX_EXECUTABLE.app/$DEVBOX_EXECUTABLE" || echo "The app is not running."
48
+
49
+ If it crashed, the exception and crashing frames from the newest crash report:
50
+ 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}]}"
51
+
52
+ Screenshot. Then copy it to your machine with: expo-devbox pull .devbox/screenshot.png .devbox/
53
+ mkdir -p .devbox && xcrun simctl io booted screenshot .devbox/screenshot.png
54
+
55
+ After adding or removing native files, or editing a podspec:
56
+ cd ios && pod install
57
+
58
+ After changing package.json, the app config or a config plugin (use your package manager;
59
+ skip prebuild if you commit ios/):
60
+ bun install && npx expo prebuild --platform ios --no-install && cd ios && pod install
21
61
 
22
62
  Requires a logged-in eas-cli and cloudflared (brew install cloudflared).
23
63
  Docs: https://www.npmjs.com/package/expo-devbox`;
24
64
 
25
65
  const commands: Record<string, (args: string[]) => unknown> = {
26
- init, start, build, ssh: shell, logs, sync, attach, joblog, stop,
66
+ init, start, sync, ssh: shell, pull, sessions, attach, joblog, stop,
27
67
  };
28
68
 
29
- const [name, ...args] = process.argv.slice(2);
69
+ const { command: name, args, session } = parseArgs(process.argv.slice(2));
70
+ selectSession(session ?? process.env.EXPO_DEVBOX_SESSION);
30
71
  if (!name || name === 'help' || name === '--help' || name === '-h') {
31
72
  if (process.stdout.isTTY) await playIntro();
32
73
  console.log(HELP);
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,15 @@ 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. Commands use the session you started last in this directory. If another agent works in the same directory, pass your session's ID, which \`start\` prints, to every command: \`bunx expo-devbox ssh --session <id> '<command>'\`.
19
+ 5. When the user is done with the devbox, run \`bunx expo-devbox stop\`.
18
20
  `;
19
21
 
20
22
  export function init(args: string[]) {
@@ -33,11 +35,6 @@ export function init(args: string[]) {
33
35
  done.push(`Wrote ${WORKFLOW}.`);
34
36
  }
35
37
 
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
38
  const gitignore = existsSync('.gitignore') ? readFileSync('.gitignore', 'utf8') : '';
42
39
  if (!/^\/?\.devbox\/?$/m.test(gitignore)) {
43
40
  appendFileSync('.gitignore', `${gitignore && !gitignore.endsWith('\n') ? '\n' : ''}\n# expo-devbox session state\n.devbox/\n`);
@@ -62,6 +59,6 @@ export function init(args: string[]) {
62
59
  console.log(`
63
60
  Next:
64
61
  ${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`);
62
+ bunx expo-devbox help the commands, and how to build and run the app on the worker
63
+ bunx expo-devbox start start a session (EAS macOS minutes are billed while it runs)`);
67
64
  }
package/src/session.ts CHANGED
@@ -1,22 +1,28 @@
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
+ *
6
+ * A directory can have several sessions. Each one is a workflow run, and its
7
+ * files are in .devbox/sessions/<run ID>/. Commands use the current session,
8
+ * the one started or attached last, unless --session names another.
5
9
  */
6
10
  import { $ } from 'bun';
7
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
12
  import { homedir } from 'node:os';
9
13
  import { join, resolve } from 'node:path';
10
14
 
15
+ import { matchSession, shortId } from './args';
11
16
  import { status } from './status';
12
17
 
13
18
  export const WORKFLOW = '.eas/workflows/ios-devbox.yml';
14
19
  const STATE = '.devbox';
15
- const SSH_CONFIG = join(STATE, 'ssh_config');
16
- const RUN_ID = join(STATE, 'run_id');
20
+ const SESSIONS = join(STATE, 'sessions');
21
+ const CURRENT = join(STATE, 'current');
17
22
  const KEY = join(homedir(), '.ssh', 'eas_devbox');
18
- const WORKER_SCRIPT = join(import.meta.dir, 'worker-build.ts');
19
23
  const ACTIVE = new Set(['NEW', 'WAITING', 'IN_PROGRESS', 'ACTION_REQUIRED']);
24
+ /** ssh's exit code when it could not connect, as opposed to the remote command's own code. */
25
+ const SSH_FAILED = 255;
20
26
 
21
27
  /** An error whose message is meant for the person or agent running the command. */
22
28
  export class UserError extends Error {}
@@ -25,10 +31,42 @@ const fail = (message: string): never => {
25
31
  };
26
32
  const log = (message: string) => console.error(message);
27
33
  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
34
 
31
- type Run = { status: string; jobs?: { id: string }[] };
35
+ type Run = { status: string; createdAt?: string; jobs?: { id: string }[] };
36
+ type Session = { id: string; dir: string; sshConfig: string };
37
+
38
+ const sessionAt = (id: string): Session => ({ id, dir: join(SESSIONS, id), sshConfig: join(SESSIONS, id, 'ssh_config') });
39
+
40
+ /** Session IDs in this directory, newest first. Workflow run IDs sort by start time. */
41
+ function sessionIds(): string[] {
42
+ return existsSync(SESSIONS) ? readdirSync(SESSIONS).sort().reverse() : [];
43
+ }
44
+
45
+ let selected: string | undefined;
46
+ /** Sets the session that commands use, from --session or EXPO_DEVBOX_SESSION: an ID or a unique prefix of one. */
47
+ export function selectSession(prefix: string | undefined) {
48
+ selected = prefix || undefined;
49
+ }
50
+
51
+ /** The session named with --session, else the current one, else the newest. */
52
+ function resolveSession(prefix = selected): Session {
53
+ const ids = sessionIds();
54
+ if (prefix) {
55
+ try {
56
+ return sessionAt(matchSession(ids, prefix));
57
+ } catch (error) {
58
+ fail((error as Error).message);
59
+ }
60
+ }
61
+ if (ids.length === 0) fail('No devbox session in this directory. Start one with: expo-devbox start');
62
+ const current = existsSync(CURRENT) ? readFileSync(CURRENT, 'utf8').trim() : '';
63
+ return sessionAt(ids.includes(current) ? current : ids[0]!);
64
+ }
65
+
66
+ function forget(session: Session) {
67
+ rmSync(session.dir, { recursive: true, force: true });
68
+ if (existsSync(CURRENT) && readFileSync(CURRENT, 'utf8').trim() === session.id) rmSync(CURRENT);
69
+ }
32
70
 
33
71
  async function viewRun(runId: string): Promise<Run | undefined> {
34
72
  const result = await $`eas workflow:view ${runId} --json --non-interactive`.quiet().nothrow();
@@ -40,10 +78,11 @@ async function viewRun(runId: string): Promise<Run | undefined> {
40
78
  }
41
79
 
42
80
  /** Fails when the run has ended. A run that cannot be read (a network error) counts as running. */
43
- async function requireActive(runId: string): Promise<Run | undefined> {
44
- const run = await viewRun(runId);
81
+ async function requireActive(session: Session): Promise<Run | undefined> {
82
+ const run = await viewRun(session.id);
45
83
  if (run && !ACTIVE.has(run.status)) {
46
- fail(`The devbox session ended with status ${run.status}. See: eas workflow:view ${runId}`);
84
+ forget(session);
85
+ fail(`The devbox session ${shortId(session.id)} ended with status ${run.status}. Start a new one with: expo-devbox start`);
47
86
  }
48
87
  if (run?.status === 'ACTION_REQUIRED') {
49
88
  status('blocked', { kind: 'permission', msg: 'The devbox workflow run needs approval on expo.dev' });
@@ -51,18 +90,8 @@ async function requireActive(runId: string): Promise<Run | undefined> {
51
90
  return run;
52
91
  }
53
92
 
54
- function savedRun(): string {
55
- if (!existsSync(RUN_ID)) fail('No devbox session. Start one with: expo-devbox start');
56
- return readFileSync(RUN_ID, 'utf8').trim();
57
- }
58
-
59
93
  function requireProject() {
60
- if (!existsSync(WORKFLOW)) fail(`${WORKFLOW} is missing. Run expo-devbox init in the project root first.`);
61
- mkdirSync(STATE, { recursive: true });
62
- }
63
-
64
- function ssh(remoteCommand: string) {
65
- return $`ssh -F ${SSH_CONFIG} devbox ${remoteCommand}`;
94
+ if (!existsSync(WORKFLOW)) fail(`${WORKFLOW} is missing. Run expo-devbox init in the app's root directory first.`);
66
95
  }
67
96
 
68
97
  /**
@@ -71,13 +100,13 @@ function ssh(remoteCommand: string) {
71
100
  * new tunnel, with a new host, if the old one exits. A host that does not
72
101
  * answer is skipped until a newer one appears in the log.
73
102
  */
74
- async function connect(runId: string): Promise<void> {
75
- log(`Waiting for the worker to open its tunnel (run ${runId})...`);
103
+ async function connect(session: Session): Promise<void> {
104
+ log(`Waiting for the worker to open its tunnel (session ${shortId(session.id)})...`);
76
105
  status('working', { msg: 'Waiting for the EAS worker' });
77
106
  let deadHost = '';
78
107
  const deadline = Date.now() + 15 * 60_000;
79
108
  while (Date.now() < deadline) {
80
- const jobId = (await requireActive(runId))?.jobs?.[0]?.id;
109
+ const jobId = (await requireActive(session))?.jobs?.[0]?.id;
81
110
  if (jobId) {
82
111
  const logs = (await $`eas workflow:logs ${jobId} --non-interactive`.quiet().nothrow()).stdout.toString();
83
112
  const last = (pattern: RegExp) => [...logs.matchAll(pattern)].at(-1)?.[1];
@@ -85,11 +114,11 @@ async function connect(runId: string): Promise<void> {
85
114
  const user = last(/DEVBOX_SSH_USER=([A-Za-z0-9_-]+)/g);
86
115
  const hostKey = last(/DEVBOX_SSH_HOSTKEY=(ssh-ed25519 [A-Za-z0-9+/=]+)/g);
87
116
  if (host && user && hostKey && host !== deadHost) {
88
- writeSshConfig(host, user, hostKey);
117
+ writeSshConfig(session, host, user, hostKey);
89
118
  let error = '';
90
119
  // A new quick tunnel can take a few seconds to resolve in DNS.
91
120
  for (let attempt = 0; attempt < 8; attempt += 1) {
92
- const result = await $`ssh -F ${SSH_CONFIG} devbox true`.quiet().nothrow();
121
+ const result = await $`ssh -F ${session.sshConfig} devbox true`.quiet().nothrow();
93
122
  if (result.exitCode === 0) {
94
123
  log(`Connected to ${user}@${host}.`);
95
124
  return;
@@ -103,19 +132,20 @@ async function connect(runId: string): Promise<void> {
103
132
  }
104
133
  await sleep(10);
105
134
  }
106
- fail(`Could not reach the worker for 15 minutes. See: eas workflow:view ${runId}`);
135
+ fail('Could not reach the worker for 15 minutes. See: expo-devbox joblog');
107
136
  }
108
137
 
109
- function writeSshConfig(host: string, user: string, hostKey: string) {
110
- writeFileSync(join(STATE, 'known_hosts'), `${host} ${hostKey}\n`);
111
- writeFileSync(SSH_CONFIG, `Host devbox
138
+ function writeSshConfig(session: Session, host: string, user: string, hostKey: string) {
139
+ const knownHosts = join(session.dir, 'known_hosts');
140
+ writeFileSync(knownHosts, `${host} ${hostKey}\n`);
141
+ writeFileSync(session.sshConfig, `Host devbox
112
142
  HostName ${host}
113
143
  User ${user}
114
144
  ProxyCommand cloudflared access ssh --hostname %h
115
145
  IdentityFile ${KEY}
116
146
  IdentitiesOnly yes
117
147
  StrictHostKeyChecking yes
118
- UserKnownHostsFile ${resolve(STATE, 'known_hosts')}
148
+ UserKnownHostsFile ${resolve(knownHosts)}
119
149
  ConnectTimeout 20
120
150
  ServerAliveInterval 30
121
151
  ControlMaster auto
@@ -124,19 +154,30 @@ function writeSshConfig(host: string, user: string, hostKey: string) {
124
154
  `);
125
155
  }
126
156
 
127
- /** Waits for the worker to finish preparing the project. Reconnects if the worker is unreachable. */
128
- async function waitUntilReady(runId: string) {
157
+ /**
158
+ * Reconnects first if the worker is unreachable, for example because its
159
+ * tunnel moved. A command is never run twice: if the connection drops while
160
+ * it runs, it may already have done its work.
161
+ */
162
+ async function ensureConnected(session: Session) {
163
+ const probe = await $`ssh -F ${session.sshConfig} devbox true`.quiet().nothrow();
164
+ if (probe.exitCode === SSH_FAILED) await connect(session);
165
+ }
166
+ 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.';
167
+
168
+ /** Waits for the worker to prepare the app: install, prebuild, pods, simulator, Metro and a first build. */
169
+ async function waitUntilReady(session: Session) {
170
+ log('Waiting for the worker to prepare the app (about 3 minutes)...');
171
+ status('working', { msg: 'The EAS worker is preparing the app' });
129
172
  const deadline = Date.now() + 20 * 60_000;
130
173
  while (Date.now() < deadline) {
131
- const result = await ssh('test -f ~/.devbox/ready').quiet().nothrow();
174
+ const result = await $`ssh -F ${session.sshConfig} devbox ${'test -f ~/.devbox/ready'}`.quiet().nothrow();
132
175
  if (result.exitCode === 0) return;
133
- if (result.exitCode === 255) {
134
- await connect(runId);
176
+ if (result.exitCode === SSH_FAILED) {
177
+ await connect(session);
135
178
  continue;
136
179
  }
137
- 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' });
180
+ await requireActive(session);
140
181
  await sleep(15);
141
182
  }
142
183
  fail('The worker is not ready after 20 minutes. See: expo-devbox joblog');
@@ -145,13 +186,6 @@ async function waitUntilReady(runId: string) {
145
186
  export async function start(args: string[]) {
146
187
  requireProject();
147
188
  if (!Bun.which('cloudflared')) fail('cloudflared is not installed. Install it with: brew install cloudflared');
148
- if (existsSync(RUN_ID)) {
149
- const previous = readFileSync(RUN_ID, 'utf8').trim();
150
- const run = await viewRun(previous);
151
- if (run && ACTIVE.has(run.status)) {
152
- fail(`A devbox session is already running (run ${previous}). Use expo-devbox attach or expo-devbox stop.`);
153
- }
154
- }
155
189
  const [minutes = '60', idleMinutes = '15'] = args;
156
190
  if (!/^\d+$/.test(minutes) || !/^\d+$/.test(idleMinutes)) fail('Usage: expo-devbox start [minutes] [idle-minutes]');
157
191
  if (!existsSync(KEY)) await $`ssh-keygen -q -t ed25519 -N ${''} -C eas-devbox -f ${KEY}`;
@@ -161,38 +195,66 @@ export async function start(args: string[]) {
161
195
  const result = await $`eas workflow:run ${WORKFLOW} --non-interactive --json -F ${`ssh_public_key=${publicKey}`} \
162
196
  -F ${`minutes=${minutes}`} -F ${`idle_minutes=${idleMinutes}`}`.env({ ...process.env, EAS_NO_VCS: '1' }).quiet().nothrow();
163
197
  if (result.exitCode !== 0) fail(`eas workflow:run failed:\n${result.stderr.toString().trim()}`);
164
- const runId: string = JSON.parse(result.stdout.toString()).id;
165
- writeFileSync(RUN_ID, runId);
166
- log(`Started run ${runId}: ${minutes} minutes at most, ending after ${idleMinutes} idle minutes.`);
198
+ const session = sessionAt(JSON.parse(result.stdout.toString()).id);
199
+ mkdirSync(session.dir, { recursive: true });
200
+ writeFileSync(CURRENT, session.id);
201
+ log(`Started session ${shortId(session.id)}: ${minutes} minutes at most, ending after ${idleMinutes} minutes without an SSH connection.`);
167
202
 
168
203
  // Cancel the run if this command fails or is interrupted, so it does not bill with nobody connected.
169
- const cancel = () => $`eas workflow:cancel ${runId} --non-interactive`.quiet().nothrow();
204
+ const cancel = async () => {
205
+ await $`eas workflow:cancel ${session.id} --non-interactive`.quiet().nothrow();
206
+ forget(session);
207
+ };
170
208
  const onInterrupt = async () => {
171
- log(`Cancelling run ${runId}.`);
209
+ log(`Cancelling session ${shortId(session.id)}.`);
172
210
  await cancel();
173
211
  status('idle');
174
212
  process.exit(130);
175
213
  };
176
214
  process.once('SIGINT', onInterrupt);
177
215
  try {
178
- await connect(runId);
216
+ await connect(session);
217
+ await waitUntilReady(session);
179
218
  } catch (error) {
180
- log(`Cancelling run ${runId}.`);
219
+ log(`Cancelling session ${shortId(session.id)}.`);
181
220
  await cancel();
182
221
  throw error;
183
222
  } finally {
184
223
  process.removeListener('SIGINT', onInterrupt);
185
224
  }
186
- log(`The worker is still preparing the project. 'expo-devbox build' waits for it.`);
187
- status('done', { msg: 'Connected to the EAS worker' });
225
+ const others = sessionIds().filter(id => id !== session.id).length;
226
+ console.log(`Session ${shortId(session.id)} is ready and is now the current session.${others ? ` This directory has ${others} other session${others === 1 ? '' : 's'}; see expo-devbox sessions.` : ''}`);
227
+ console.log('Run expo-devbox help and follow "On the worker" to build and run the app.');
228
+ status('done', { msg: 'The EAS worker is ready' });
188
229
  }
189
230
 
190
231
  export async function attach(args: string[]) {
191
232
  requireProject();
192
- const runId = args[0] ?? savedRun();
193
- writeFileSync(RUN_ID, runId);
194
- await connect(runId);
195
- status('done', { msg: 'Connected to the EAS worker' });
233
+ const session = resolveSession(args[0] ?? selected);
234
+ writeFileSync(CURRENT, session.id);
235
+ await connect(session);
236
+ await waitUntilReady(session);
237
+ console.log(`Session ${shortId(session.id)} is ready and is now the current session.`);
238
+ status('done', { msg: 'The EAS worker is ready' });
239
+ }
240
+
241
+ /** Lists this directory's sessions, newest first, and forgets the ones that have ended. */
242
+ export async function sessions() {
243
+ requireProject();
244
+ const ids = sessionIds();
245
+ if (ids.length === 0) {
246
+ console.log('No devbox sessions in this directory. Start one with: expo-devbox start');
247
+ return;
248
+ }
249
+ const current = resolveSession('').id;
250
+ for (const id of ids) {
251
+ const run = await viewRun(id);
252
+ const started = run?.createdAt ? new Date(run.createdAt).toLocaleString() : '';
253
+ const state = run?.status ?? 'UNKNOWN';
254
+ console.log(`${id === current ? '*' : ' '} ${shortId(id)} ${state.padEnd(15)} ${started}`);
255
+ if (run && !ACTIVE.has(run.status)) forget(sessionAt(id));
256
+ }
257
+ console.log('\n* the current session. Pick another with --session <id> or EXPO_DEVBOX_SESSION=<id>; any unique prefix works.');
196
258
  }
197
259
 
198
260
  /**
@@ -202,79 +264,88 @@ export async function attach(args: string[]) {
202
264
  */
203
265
  export async function sync() {
204
266
  requireProject();
267
+ const session = resolveSession();
205
268
  const gitignore = existsSync('.gitignore')
206
269
  ? readFileSync('.gitignore', 'utf8').split('\n').filter(line => !/^\s*!/.test(line))
207
270
  : [];
208
271
  const ignored = (await $`git ls-files -oi --exclude-standard --directory`.quiet().nothrow()).stdout.toString()
209
272
  .split('\n').filter(Boolean).map(path => `/${path}`);
210
- const excludes = join(STATE, 'rsync-excludes');
273
+ const excludes = join(session.dir, 'rsync-excludes');
211
274
  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();
275
+ await ensureConnected(session);
276
+ const { exitCode } = await $`rsync -az --delete -e ${`ssh -F ${session.sshConfig}`} \
277
+ --exclude .git --exclude node_modules --exclude /.devbox --exclude /ios/Pods --exclude-from=${excludes} \
278
+ ./ devbox:project/`.nothrow();
279
+ if (exitCode !== 0) fail(`rsync exited ${exitCode}. Run expo-devbox sync again; it only sends what changed.`);
246
280
  }
247
281
 
248
282
  export async function shell(args: string[]) {
249
283
  requireProject();
284
+ const session = resolveSession();
250
285
  if (args.length === 0) {
251
- const child = Bun.spawn(['ssh', '-F', SSH_CONFIG, '-t', 'devbox', 'cd ~/project && exec $SHELL'],
286
+ const child = Bun.spawn(['ssh', '-F', session.sshConfig, '-t', 'devbox', 'cd ~/project && exec $SHELL'],
252
287
  { stdio: ['inherit', 'inherit', 'inherit'] });
253
288
  process.exitCode = await child.exited;
254
289
  return;
255
290
  }
256
- process.exitCode = (await ssh(`cd ~/project && ${args.join(' ')}`).nothrow()).exitCode;
291
+ await ensureConnected(session);
292
+ const { exitCode } = await $`ssh -F ${session.sshConfig} devbox ${`cd ~/project && ${args.join(' ')}`}`.nothrow();
293
+ if (exitCode === SSH_FAILED) console.error(droppedMessage);
294
+ process.exitCode = exitCode;
295
+ }
296
+
297
+ /** Copies a file from the worker. A relative path is relative to the app's directory there. */
298
+ export async function pull(args: string[]) {
299
+ requireProject();
300
+ const [path, destination = '.'] = args;
301
+ if (!path) throw new UserError('Usage: expo-devbox pull <path on the worker> [local directory or file]');
302
+ const session = resolveSession();
303
+ await ensureConnected(session);
304
+ const remote = path.startsWith('/') || path.startsWith('~') ? path : `project/${path}`;
305
+ const { exitCode } = await $`scp -q -F ${session.sshConfig} ${`devbox:${remote}`} ${destination}`.nothrow();
306
+ if (exitCode !== 0) fail(`Could not copy ${path} from the worker (scp exited ${exitCode}).`);
257
307
  }
258
308
 
259
309
  export async function joblog() {
260
310
  requireProject();
261
- 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());
311
+ const session = resolveSession();
312
+ const jobId = (await viewRun(session.id))?.jobs?.[0]?.id;
313
+ if (!jobId) fail(`Could not find the job of session ${shortId(session.id)}. See: eas workflow:view ${session.id}`);
263
314
  await $`eas workflow:logs ${jobId} --non-interactive`.nothrow();
264
315
  }
265
316
 
266
- export async function stop() {
267
- requireProject();
268
- const runId = savedRun();
269
- status('working', { msg: 'Ending the devbox session' });
270
- await $`eas workflow:cancel ${runId} --non-interactive`.quiet().nothrow();
317
+ async function stopOne(session: Session): Promise<string> {
318
+ await $`eas workflow:cancel ${session.id} --non-interactive`.quiet().nothrow();
271
319
  // eas workflow:cancel exits 0 even when it fails, so wait for the run's final status instead.
272
- const result = await $`eas workflow:status ${runId} --wait --non-interactive`.quiet().nothrow();
273
- await $`ssh -F ${SSH_CONFIG} -O exit devbox`.quiet().nothrow();
320
+ const result = await $`eas workflow:status ${session.id} --wait --non-interactive`.quiet().nothrow();
321
+ await $`ssh -F ${session.sshConfig} -O exit devbox`.quiet().nothrow();
274
322
  const ended: Record<number, string> = { 0: 'had already finished', 11: 'had already failed', 12: 'is canceled' };
275
323
  if (!(result.exitCode in ended)) {
276
- fail(`Could not confirm that run ${runId} ended (eas workflow:status exited ${result.exitCode}). Check: eas workflow:view ${runId}`);
324
+ fail(`Could not confirm that session ${shortId(session.id)} ended (eas workflow:status exited ${result.exitCode}). Check: eas workflow:view ${session.id}`);
325
+ }
326
+ forget(session);
327
+ return `The devbox session ${shortId(session.id)} ${ended[result.exitCode]}.`;
328
+ }
329
+
330
+ export async function stop(args: string[]) {
331
+ requireProject();
332
+ const all = args.includes('--all');
333
+ const targets = all ? sessionIds().map(sessionAt) : [resolveSession()];
334
+ if (targets.length === 0) {
335
+ console.log('No devbox sessions in this directory.');
336
+ return;
337
+ }
338
+ status('working', { msg: all ? 'Ending all devbox sessions' : 'Ending the devbox session' });
339
+ // With --all, one session that cannot be confirmed does not stop the others from ending.
340
+ const failures: string[] = [];
341
+ for (const session of targets) {
342
+ try {
343
+ console.log(await stopOne(session));
344
+ } catch (error) {
345
+ if (!(error instanceof UserError)) throw error;
346
+ failures.push(error.message);
347
+ }
277
348
  }
278
- console.log(`The devbox session ${ended[result.exitCode]}.`);
279
- status('done', { msg: 'Devbox session ended' });
349
+ if (failures.length) fail(failures.join('\n'));
350
+ status('done', { msg: all ? 'All devbox sessions ended' : 'Devbox session ended' });
280
351
  }
@@ -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
- }