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 +137 -104
- package/package.json +1 -1
- package/src/args.ts +49 -0
- package/src/cli.ts +56 -15
- package/src/init.ts +11 -14
- package/src/session.ts +181 -110
- package/templates/ios-devbox.yml +23 -8
- package/src/worker-build.ts +0 -181
package/README.md
CHANGED
|
@@ -1,145 +1,185 @@
|
|
|
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 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
|
|
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
|
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
104
|
+
<details>
|
|
105
|
+
<summary id="variables"><strong>The <code>DEVBOX_*</code> variables</strong></summary>
|
|
67
106
|
|
|
68
|
-
|
|
|
107
|
+
| Variable | Value |
|
|
69
108
|
| --- | --- |
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
##
|
|
119
|
+
## Additional information
|
|
97
120
|
|
|
98
|
-
|
|
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
|
-
|
|
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.
|
|
106
|
-
2.
|
|
107
|
-
3.
|
|
108
|
-
4.
|
|
109
|
-
5. When
|
|
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
|
-
|
|
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
|
-
|
|
142
|
+
</details>
|
|
115
143
|
|
|
116
|
-
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
+
</details>
|
|
139
176
|
|
|
140
|
-
##
|
|
177
|
+
## Contributing
|
|
141
178
|
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
172
|
-
| A
|
|
173
|
-
|
|
|
174
|
-
|
|
|
175
|
-
|
|
|
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 `
|
|
178
|
-
| `
|
|
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
|
-
|
|
213
|
+
MIT
|
package/package.json
CHANGED
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 {
|
|
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 = `
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
start
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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,
|
|
66
|
+
init, start, sync, ssh: shell, pull, sessions, attach, joblog, stop,
|
|
27
67
|
};
|
|
28
68
|
|
|
29
|
-
const
|
|
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
|
|
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
|
-
|
|
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. 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
|
|
66
|
-
bunx expo-devbox
|
|
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
|
|
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
|
+
*
|
|
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
|
|
16
|
-
const
|
|
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(
|
|
44
|
-
const run = await viewRun(
|
|
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
|
-
|
|
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
|
|
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(
|
|
75
|
-
log(`Waiting for the worker to open its tunnel (
|
|
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(
|
|
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 ${
|
|
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(
|
|
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
|
-
|
|
111
|
-
writeFileSync(
|
|
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(
|
|
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
|
-
/**
|
|
128
|
-
|
|
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
|
|
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 ===
|
|
134
|
-
await connect(
|
|
176
|
+
if (result.exitCode === SSH_FAILED) {
|
|
177
|
+
await connect(session);
|
|
135
178
|
continue;
|
|
136
179
|
}
|
|
137
|
-
await requireActive(
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
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 = () =>
|
|
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
|
|
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(
|
|
216
|
+
await connect(session);
|
|
217
|
+
await waitUntilReady(session);
|
|
179
218
|
} catch (error) {
|
|
180
|
-
log(`Cancelling
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
193
|
-
writeFileSync(
|
|
194
|
-
await connect(
|
|
195
|
-
|
|
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(
|
|
273
|
+
const excludes = join(session.dir, 'rsync-excludes');
|
|
211
274
|
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();
|
|
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',
|
|
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
|
-
|
|
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
|
|
262
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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 ${
|
|
273
|
-
await $`ssh -F ${
|
|
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
|
|
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
|
-
|
|
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
|
}
|
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
|
-
}
|