expo-devbox 0.2.0 → 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 +86 -82
- package/package.json +1 -1
- package/src/args.ts +49 -0
- package/src/cli.ts +21 -10
- package/src/init.ts +2 -1
- package/src/session.ts +156 -68
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# expo-devbox
|
|
2
2
|
|
|
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
|
|
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
|
|
|
@@ -19,102 +19,90 @@ Set up expo-devbox (https://www.npmjs.com/package/expo-devbox) in this Expo app.
|
|
|
19
19
|
|
|
20
20
|
**iOS only for now.** Android support, on an EAS Linux worker with the Android Emulator, will be added to this tool shortly.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
<details>
|
|
23
|
+
<summary><strong>Requirements</strong></summary>
|
|
23
24
|
|
|
24
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.
|
|
25
26
|
- [Bun](https://bun.sh) 1.3 or later, and `eas-cli`, logged in.
|
|
26
27
|
- `cloudflared`: `brew install cloudflared`.
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
</details>
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
29
32
|
|
|
30
33
|
In the root of your app:
|
|
31
34
|
|
|
32
35
|
```sh
|
|
33
|
-
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
|
|
34
41
|
```
|
|
35
42
|
|
|
36
|
-
`init` writes the workflow to `.eas/workflows/ios-devbox.yml` and adds `.devbox/` to `.gitignore`; the CLI keeps the session's run ID and SSH config there. It also adds a short section for coding agents to `AGENTS.md` or `CLAUDE.md`, or prints it if neither file exists (see [Coding agents](#coding-agents)).
|
|
37
|
-
|
|
38
|
-
## Commands
|
|
39
|
-
|
|
40
43
|
| Command | What it does |
|
|
41
44
|
| --- | --- |
|
|
42
|
-
| `init [--force] [--no-agents]` |
|
|
43
|
-
| `start [minutes] [idle-minutes]` | Starts a session and waits until
|
|
44
|
-
| `sync` | Copies your files to the worker: what git would track, so `.gitignore` applies.
|
|
45
|
-
| `ssh ['command']` | Runs a command in the app's directory on the worker, or opens a shell.
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
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. |
|
|
49
53
|
|
|
50
|
-
`expo-devbox help` prints
|
|
54
|
+
`expo-devbox help` prints all of this and the steps below.
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
After each change, run `expo-devbox sync`, then run these with `expo-devbox ssh '<command>'`. The `DEVBOX_*` variables are set in every SSH session.
|
|
56
|
+
<details>
|
|
57
|
+
<summary id="sessions"><strong>Several sessions</strong></summary>
|
|
55
58
|
|
|
56
|
-
|
|
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:
|
|
57
60
|
|
|
58
61
|
```
|
|
59
|
-
$ bunx expo-devbox
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
$ bunx expo-devbox ssh 'tail -n 3 ~/.devbox/metro.log'
|
|
63
|
-
LOG [native-crypto] PASS sha384("abc"): cb00753f…
|
|
64
|
-
LOG [native-crypto] DONE 7/7 passed
|
|
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
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
|
|
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
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
</details>
|
|
70
|
+
|
|
71
|
+
## On the worker
|
|
72
72
|
|
|
73
|
-
|
|
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.
|
|
74
74
|
|
|
75
75
|
```sh
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
78
79
|
|
|
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"
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
# JavaScript console output and errors (Metro).
|
|
82
84
|
tail -n 50 ~/.devbox/metro.log
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
**Native log**: `NSLog`, and `os_log` or `Logger` output from the app's own code, from the last minute. Swift's `print` writes to stdout and does not appear here.
|
|
86
85
|
|
|
87
|
-
|
|
86
|
+
# Native log: NSLog, and os_log or Logger output from the app's own code, from the last minute.
|
|
88
87
|
xcrun simctl spawn booted log show --last 1m --style compact --predicate "$DEVBOX_LOG_PREDICATE" 2>/dev/null
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
**Check that the app is still running**, and if it crashed, read the exception and crashing frames from the newest crash report:
|
|
92
88
|
|
|
93
|
-
|
|
89
|
+
# Check that the app is still running; if it crashed, the newest crash report's exception and frames.
|
|
94
90
|
pgrep -lf "$DEVBOX_EXECUTABLE.app/$DEVBOX_EXECUTABLE" || echo "The app is not running."
|
|
95
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}]}"
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
**Screenshot**, then copy it to your machine (the second command runs locally):
|
|
99
92
|
|
|
100
|
-
|
|
101
|
-
xcrun simctl io booted screenshot
|
|
102
|
-
scp -F .devbox/ssh_config devbox:.devbox/screenshot.png .devbox/
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
**After adding or removing native files, or editing a podspec:**
|
|
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
|
|
106
95
|
|
|
107
|
-
|
|
96
|
+
# After adding or removing native files, or editing a podspec.
|
|
108
97
|
cd ios && pod install
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
**After changing `package.json`, the app config or a config plugin.** Use your package manager, and skip prebuild if you commit `ios/`:
|
|
112
98
|
|
|
113
|
-
|
|
99
|
+
# After changing package.json, the app config or a config plugin. Use your package manager;
|
|
100
|
+
# skip prebuild if you commit ios/.
|
|
114
101
|
bun install && npx expo prebuild --platform ios --no-install && cd ios && pod install
|
|
115
102
|
```
|
|
116
103
|
|
|
117
|
-
|
|
104
|
+
<details>
|
|
105
|
+
<summary id="variables"><strong>The <code>DEVBOX_*</code> variables</strong></summary>
|
|
118
106
|
|
|
119
107
|
| Variable | Value |
|
|
120
108
|
| --- | --- |
|
|
@@ -122,9 +110,16 @@ bun install && npx expo prebuild --platform ios --no-install && cd ios && pod in
|
|
|
122
110
|
| `DEVBOX_SIMULATOR` | The booted simulator's UDID. |
|
|
123
111
|
| `DEVBOX_DERIVED_DATA` | The DerivedData folder of the worker's first build. Build with it to keep builds incremental. |
|
|
124
112
|
| `DEVBOX_APP`, `DEVBOX_BUNDLE_ID`, `DEVBOX_EXECUTABLE` | The built `.app`, its bundle identifier, and its executable's name. |
|
|
125
|
-
| `DEVBOX_LOG_PREDICATE` | A `log` predicate for messages from the app's own code and `NSLog`, without system libraries' logging. |
|
|
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>
|
|
126
118
|
|
|
127
|
-
##
|
|
119
|
+
## Additional information
|
|
120
|
+
|
|
121
|
+
<details>
|
|
122
|
+
<summary id="coding-agents"><strong>Instructions for coding agents</strong></summary>
|
|
128
123
|
|
|
129
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:
|
|
130
125
|
|
|
@@ -138,37 +133,51 @@ When the user asks for it:
|
|
|
138
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.
|
|
139
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.
|
|
140
135
|
3. After each change, run `bunx expo-devbox sync`, then the commands from the help with `bunx expo-devbox ssh '<command>'`.
|
|
141
|
-
4.
|
|
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`.
|
|
142
138
|
```
|
|
143
139
|
|
|
144
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.
|
|
145
141
|
|
|
146
|
-
|
|
142
|
+
</details>
|
|
143
|
+
|
|
144
|
+
<details>
|
|
145
|
+
<summary id="how-it-works"><strong>How it works</strong></summary>
|
|
147
146
|
|
|
148
147
|
The workflow runs one custom job on `macos-large` in the `development` environment:
|
|
149
148
|
|
|
150
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.
|
|
151
|
-
2. **Tunnel.** EAS workers accept no inbound connections, so the worker opens a pinned `cloudflared` [quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/), and Cloudflare relays your connection to it. The job log prints the tunnel host and the worker's SSH host key; the CLI reads them with `eas workflow:logs` and pins the key. Quick tunnels have no uptime guarantee. If the tunnel exits, the worker opens a new one, and `sync` and `
|
|
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.
|
|
152
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.
|
|
153
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.
|
|
154
153
|
|
|
155
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.
|
|
156
155
|
|
|
157
156
|
> [!NOTE]
|
|
158
|
-
> SSH support for EAS Workflows is coming soon (`eas workflow:run --ssh` and `eas workflow:ssh`). It will cover about half of what this tool does: the SSH server, the Cloudflare tunnel, finding the worker, and ending idle sessions. The other half stays: preparing the worker
|
|
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>
|
|
159
160
|
|
|
160
|
-
|
|
161
|
+
<details>
|
|
162
|
+
<summary><strong>Known limitations</strong></summary>
|
|
161
163
|
|
|
162
164
|
- iOS only. Android support will be added shortly.
|
|
163
165
|
- One app per project root. Monorepos with dependencies outside the app directory are not supported.
|
|
164
|
-
- The `DEVBOX_*` values assume prebuild's naming, where the scheme, the app and its executable share a name. A project that commits `ios/` with different names can use its own values in the commands.
|
|
165
166
|
- Private npm packages need their token as an EAS environment variable in the `development` environment.
|
|
166
167
|
|
|
167
|
-
|
|
168
|
+
</details>
|
|
169
|
+
|
|
170
|
+
<details>
|
|
171
|
+
<summary><strong>The intro animation</strong></summary>
|
|
168
172
|
|
|
169
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.
|
|
170
174
|
|
|
171
|
-
|
|
175
|
+
</details>
|
|
176
|
+
|
|
177
|
+
## Contributing
|
|
178
|
+
|
|
179
|
+
<details>
|
|
180
|
+
<summary><strong>Develop, test, and check a release</strong></summary>
|
|
172
181
|
|
|
173
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.
|
|
174
183
|
|
|
@@ -179,19 +188,9 @@ bun test
|
|
|
179
188
|
bun link && bun install -g . # put this checkout on your PATH as expo-devbox
|
|
180
189
|
```
|
|
181
190
|
|
|
182
|
-
|
|
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.
|
|
183
192
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
The example does not depend on `expo-devbox`. Run the linked CLI from the example's directory:
|
|
187
|
-
|
|
188
|
-
```sh
|
|
189
|
-
cd example
|
|
190
|
-
bun install
|
|
191
|
-
expo-devbox start
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
To use your own EAS account, delete `owner` and `extra.eas` from `example/app.json` and run `eas init` in `example/`.
|
|
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.
|
|
195
194
|
|
|
196
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:
|
|
197
196
|
|
|
@@ -204,6 +203,11 @@ Before a release, run these checks in the example with the commands from [On the
|
|
|
204
203
|
| Prebuild and pod install, then build | `** BUILD SUCCEEDED **` |
|
|
205
204
|
| `.env.local` present, then `sync` | the file is not on the worker |
|
|
206
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 |
|
|
207
|
-
| `
|
|
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
|
|
208
212
|
|
|
209
|
-
|
|
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,22 +1,30 @@
|
|
|
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
8
|
const HELP = `A macOS EAS worker for building and testing an Expo app's native iOS code,
|
|
8
9
|
for you or a coding agent. Run these in the app's root directory.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
Commands:
|
|
11
12
|
init [--force] [--no-agents] add the devbox workflow to this app
|
|
12
|
-
start [minutes] [idle-minutes] start a session and wait until
|
|
13
|
+
start [minutes] [idle-minutes] start a new session and wait until its worker is ready (about 4 minutes).
|
|
13
14
|
It ends after [minutes] (default 60) or after [idle-minutes] without
|
|
14
15
|
an SSH connection (default 15). EAS macOS minutes are billed until then.
|
|
15
16
|
sync copy your files to the worker (what git would track; .gitignore applies)
|
|
16
17
|
ssh ['command'] run a command in the app's directory on the worker, or open a shell
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
23
|
+
|
|
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.
|
|
20
28
|
|
|
21
29
|
On the worker:
|
|
22
30
|
After each change, run expo-devbox sync, then the commands below with expo-devbox ssh '<command>'.
|
|
@@ -41,8 +49,8 @@ On the worker:
|
|
|
41
49
|
If it crashed, the exception and crashing frames from the newest crash report:
|
|
42
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}]}"
|
|
43
51
|
|
|
44
|
-
Screenshot
|
|
45
|
-
xcrun simctl io booted screenshot
|
|
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
|
|
46
54
|
|
|
47
55
|
After adding or removing native files, or editing a podspec:
|
|
48
56
|
cd ios && pod install
|
|
@@ -54,9 +62,12 @@ On the worker:
|
|
|
54
62
|
Requires a logged-in eas-cli and cloudflared (brew install cloudflared).
|
|
55
63
|
Docs: https://www.npmjs.com/package/expo-devbox`;
|
|
56
64
|
|
|
57
|
-
const commands: Record<string, (args: string[]) => unknown> = {
|
|
65
|
+
const commands: Record<string, (args: string[]) => unknown> = {
|
|
66
|
+
init, start, sync, ssh: shell, pull, sessions, attach, joblog, stop,
|
|
67
|
+
};
|
|
58
68
|
|
|
59
|
-
const
|
|
69
|
+
const { command: name, args, session } = parseArgs(process.argv.slice(2));
|
|
70
|
+
selectSession(session ?? process.env.EXPO_DEVBOX_SESSION);
|
|
60
71
|
if (!name || name === 'help' || name === '--help' || name === '-h') {
|
|
61
72
|
if (process.stdout.isTTY) await playIntro();
|
|
62
73
|
console.log(HELP);
|
package/src/init.ts
CHANGED
|
@@ -15,7 +15,8 @@ When the user asks for it:
|
|
|
15
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
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
17
|
3. After each change, run \`bunx expo-devbox sync\`, then the commands from the help with \`bunx expo-devbox ssh '<command>'\`.
|
|
18
|
-
4.
|
|
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\`.
|
|
19
20
|
`;
|
|
20
21
|
|
|
21
22
|
export function init(args: string[]) {
|
package/src/session.ts
CHANGED
|
@@ -2,18 +2,23 @@
|
|
|
2
2
|
* The session commands: start a devbox workflow run, connect to its worker
|
|
3
3
|
* over SSH through the Cloudflare tunnel it opens, sync files, run commands,
|
|
4
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, 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
23
|
const ACTIVE = new Set(['NEW', 'WAITING', 'IN_PROGRESS', 'ACTION_REQUIRED']);
|
|
19
24
|
/** ssh's exit code when it could not connect, as opposed to the remote command's own code. */
|
|
@@ -27,7 +32,41 @@ const fail = (message: string): never => {
|
|
|
27
32
|
const log = (message: string) => console.error(message);
|
|
28
33
|
const sleep = (seconds: number) => Bun.sleep(seconds * 1000);
|
|
29
34
|
|
|
30
|
-
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
|
+
}
|
|
31
70
|
|
|
32
71
|
async function viewRun(runId: string): Promise<Run | undefined> {
|
|
33
72
|
const result = await $`eas workflow:view ${runId} --json --non-interactive`.quiet().nothrow();
|
|
@@ -39,10 +78,11 @@ async function viewRun(runId: string): Promise<Run | undefined> {
|
|
|
39
78
|
}
|
|
40
79
|
|
|
41
80
|
/** Fails when the run has ended. A run that cannot be read (a network error) counts as running. */
|
|
42
|
-
async function requireActive(
|
|
43
|
-
const run = await viewRun(
|
|
81
|
+
async function requireActive(session: Session): Promise<Run | undefined> {
|
|
82
|
+
const run = await viewRun(session.id);
|
|
44
83
|
if (run && !ACTIVE.has(run.status)) {
|
|
45
|
-
|
|
84
|
+
forget(session);
|
|
85
|
+
fail(`The devbox session ${shortId(session.id)} ended with status ${run.status}. Start a new one with: expo-devbox start`);
|
|
46
86
|
}
|
|
47
87
|
if (run?.status === 'ACTION_REQUIRED') {
|
|
48
88
|
status('blocked', { kind: 'permission', msg: 'The devbox workflow run needs approval on expo.dev' });
|
|
@@ -50,14 +90,8 @@ async function requireActive(runId: string): Promise<Run | undefined> {
|
|
|
50
90
|
return run;
|
|
51
91
|
}
|
|
52
92
|
|
|
53
|
-
function savedRun(): string {
|
|
54
|
-
if (!existsSync(RUN_ID)) fail('No devbox session. Start one with: expo-devbox start');
|
|
55
|
-
return readFileSync(RUN_ID, 'utf8').trim();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
93
|
function requireProject() {
|
|
59
94
|
if (!existsSync(WORKFLOW)) fail(`${WORKFLOW} is missing. Run expo-devbox init in the app's root directory first.`);
|
|
60
|
-
mkdirSync(STATE, { recursive: true });
|
|
61
95
|
}
|
|
62
96
|
|
|
63
97
|
/**
|
|
@@ -66,13 +100,13 @@ function requireProject() {
|
|
|
66
100
|
* new tunnel, with a new host, if the old one exits. A host that does not
|
|
67
101
|
* answer is skipped until a newer one appears in the log.
|
|
68
102
|
*/
|
|
69
|
-
async function connect(
|
|
70
|
-
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)})...`);
|
|
71
105
|
status('working', { msg: 'Waiting for the EAS worker' });
|
|
72
106
|
let deadHost = '';
|
|
73
107
|
const deadline = Date.now() + 15 * 60_000;
|
|
74
108
|
while (Date.now() < deadline) {
|
|
75
|
-
const jobId = (await requireActive(
|
|
109
|
+
const jobId = (await requireActive(session))?.jobs?.[0]?.id;
|
|
76
110
|
if (jobId) {
|
|
77
111
|
const logs = (await $`eas workflow:logs ${jobId} --non-interactive`.quiet().nothrow()).stdout.toString();
|
|
78
112
|
const last = (pattern: RegExp) => [...logs.matchAll(pattern)].at(-1)?.[1];
|
|
@@ -80,11 +114,11 @@ async function connect(runId: string): Promise<void> {
|
|
|
80
114
|
const user = last(/DEVBOX_SSH_USER=([A-Za-z0-9_-]+)/g);
|
|
81
115
|
const hostKey = last(/DEVBOX_SSH_HOSTKEY=(ssh-ed25519 [A-Za-z0-9+/=]+)/g);
|
|
82
116
|
if (host && user && hostKey && host !== deadHost) {
|
|
83
|
-
writeSshConfig(host, user, hostKey);
|
|
117
|
+
writeSshConfig(session, host, user, hostKey);
|
|
84
118
|
let error = '';
|
|
85
119
|
// A new quick tunnel can take a few seconds to resolve in DNS.
|
|
86
120
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
87
|
-
const result = await $`ssh -F ${
|
|
121
|
+
const result = await $`ssh -F ${session.sshConfig} devbox true`.quiet().nothrow();
|
|
88
122
|
if (result.exitCode === 0) {
|
|
89
123
|
log(`Connected to ${user}@${host}.`);
|
|
90
124
|
return;
|
|
@@ -101,16 +135,17 @@ async function connect(runId: string): Promise<void> {
|
|
|
101
135
|
fail('Could not reach the worker for 15 minutes. See: expo-devbox joblog');
|
|
102
136
|
}
|
|
103
137
|
|
|
104
|
-
function writeSshConfig(host: string, user: string, hostKey: string) {
|
|
105
|
-
|
|
106
|
-
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
|
|
107
142
|
HostName ${host}
|
|
108
143
|
User ${user}
|
|
109
144
|
ProxyCommand cloudflared access ssh --hostname %h
|
|
110
145
|
IdentityFile ${KEY}
|
|
111
146
|
IdentitiesOnly yes
|
|
112
147
|
StrictHostKeyChecking yes
|
|
113
|
-
UserKnownHostsFile ${resolve(
|
|
148
|
+
UserKnownHostsFile ${resolve(knownHosts)}
|
|
114
149
|
ConnectTimeout 20
|
|
115
150
|
ServerAliveInterval 30
|
|
116
151
|
ControlMaster auto
|
|
@@ -124,25 +159,25 @@ function writeSshConfig(host: string, user: string, hostKey: string) {
|
|
|
124
159
|
* tunnel moved. A command is never run twice: if the connection drops while
|
|
125
160
|
* it runs, it may already have done its work.
|
|
126
161
|
*/
|
|
127
|
-
async function ensureConnected() {
|
|
128
|
-
const probe = await $`ssh -F ${
|
|
129
|
-
if (probe.exitCode === SSH_FAILED) await connect(
|
|
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);
|
|
130
165
|
}
|
|
131
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.';
|
|
132
167
|
|
|
133
168
|
/** Waits for the worker to prepare the app: install, prebuild, pods, simulator, Metro and a first build. */
|
|
134
|
-
async function waitUntilReady(
|
|
169
|
+
async function waitUntilReady(session: Session) {
|
|
135
170
|
log('Waiting for the worker to prepare the app (about 3 minutes)...');
|
|
136
171
|
status('working', { msg: 'The EAS worker is preparing the app' });
|
|
137
172
|
const deadline = Date.now() + 20 * 60_000;
|
|
138
173
|
while (Date.now() < deadline) {
|
|
139
|
-
const result = await $`ssh -F ${
|
|
174
|
+
const result = await $`ssh -F ${session.sshConfig} devbox ${'test -f ~/.devbox/ready'}`.quiet().nothrow();
|
|
140
175
|
if (result.exitCode === 0) return;
|
|
141
176
|
if (result.exitCode === SSH_FAILED) {
|
|
142
|
-
await connect(
|
|
177
|
+
await connect(session);
|
|
143
178
|
continue;
|
|
144
179
|
}
|
|
145
|
-
await requireActive(
|
|
180
|
+
await requireActive(session);
|
|
146
181
|
await sleep(15);
|
|
147
182
|
}
|
|
148
183
|
fail('The worker is not ready after 20 minutes. See: expo-devbox joblog');
|
|
@@ -151,13 +186,6 @@ async function waitUntilReady(runId: string) {
|
|
|
151
186
|
export async function start(args: string[]) {
|
|
152
187
|
requireProject();
|
|
153
188
|
if (!Bun.which('cloudflared')) fail('cloudflared is not installed. Install it with: brew install cloudflared');
|
|
154
|
-
if (existsSync(RUN_ID)) {
|
|
155
|
-
const previous = readFileSync(RUN_ID, 'utf8').trim();
|
|
156
|
-
const run = await viewRun(previous);
|
|
157
|
-
if (run && ACTIVE.has(run.status)) {
|
|
158
|
-
fail(`A devbox session is already running (run ${previous}). Use it, or end it with: expo-devbox stop`);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
189
|
const [minutes = '60', idleMinutes = '15'] = args;
|
|
162
190
|
if (!/^\d+$/.test(minutes) || !/^\d+$/.test(idleMinutes)) fail('Usage: expo-devbox start [minutes] [idle-minutes]');
|
|
163
191
|
if (!existsSync(KEY)) await $`ssh-keygen -q -t ed25519 -N ${''} -C eas-devbox -f ${KEY}`;
|
|
@@ -167,43 +195,68 @@ export async function start(args: string[]) {
|
|
|
167
195
|
const result = await $`eas workflow:run ${WORKFLOW} --non-interactive --json -F ${`ssh_public_key=${publicKey}`} \
|
|
168
196
|
-F ${`minutes=${minutes}`} -F ${`idle_minutes=${idleMinutes}`}`.env({ ...process.env, EAS_NO_VCS: '1' }).quiet().nothrow();
|
|
169
197
|
if (result.exitCode !== 0) fail(`eas workflow:run failed:\n${result.stderr.toString().trim()}`);
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
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.`);
|
|
173
202
|
|
|
174
203
|
// Cancel the run if this command fails or is interrupted, so it does not bill with nobody connected.
|
|
175
|
-
const cancel = () =>
|
|
204
|
+
const cancel = async () => {
|
|
205
|
+
await $`eas workflow:cancel ${session.id} --non-interactive`.quiet().nothrow();
|
|
206
|
+
forget(session);
|
|
207
|
+
};
|
|
176
208
|
const onInterrupt = async () => {
|
|
177
|
-
log(`Cancelling
|
|
209
|
+
log(`Cancelling session ${shortId(session.id)}.`);
|
|
178
210
|
await cancel();
|
|
179
211
|
status('idle');
|
|
180
212
|
process.exit(130);
|
|
181
213
|
};
|
|
182
214
|
process.once('SIGINT', onInterrupt);
|
|
183
215
|
try {
|
|
184
|
-
await connect(
|
|
185
|
-
await waitUntilReady(
|
|
216
|
+
await connect(session);
|
|
217
|
+
await waitUntilReady(session);
|
|
186
218
|
} catch (error) {
|
|
187
|
-
log(`Cancelling
|
|
219
|
+
log(`Cancelling session ${shortId(session.id)}.`);
|
|
188
220
|
await cancel();
|
|
189
221
|
throw error;
|
|
190
222
|
} finally {
|
|
191
223
|
process.removeListener('SIGINT', onInterrupt);
|
|
192
224
|
}
|
|
193
|
-
|
|
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.');
|
|
194
228
|
status('done', { msg: 'The EAS worker is ready' });
|
|
195
229
|
}
|
|
196
230
|
|
|
197
231
|
export async function attach(args: string[]) {
|
|
198
232
|
requireProject();
|
|
199
|
-
const
|
|
200
|
-
writeFileSync(
|
|
201
|
-
await connect(
|
|
202
|
-
await waitUntilReady(
|
|
203
|
-
console.log(
|
|
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.`);
|
|
204
238
|
status('done', { msg: 'The EAS worker is ready' });
|
|
205
239
|
}
|
|
206
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.');
|
|
258
|
+
}
|
|
259
|
+
|
|
207
260
|
/**
|
|
208
261
|
* Sends what git would track. .gitignore patterns also protect worker-only
|
|
209
262
|
* files (such as a generated ios/) from --delete. rsync cannot express
|
|
@@ -211,15 +264,16 @@ export async function attach(args: string[]) {
|
|
|
211
264
|
*/
|
|
212
265
|
export async function sync() {
|
|
213
266
|
requireProject();
|
|
267
|
+
const session = resolveSession();
|
|
214
268
|
const gitignore = existsSync('.gitignore')
|
|
215
269
|
? readFileSync('.gitignore', 'utf8').split('\n').filter(line => !/^\s*!/.test(line))
|
|
216
270
|
: [];
|
|
217
271
|
const ignored = (await $`git ls-files -oi --exclude-standard --directory`.quiet().nothrow()).stdout.toString()
|
|
218
272
|
.split('\n').filter(Boolean).map(path => `/${path}`);
|
|
219
|
-
const excludes = join(
|
|
273
|
+
const excludes = join(session.dir, 'rsync-excludes');
|
|
220
274
|
writeFileSync(excludes, [...gitignore, ...ignored].join('\n') + '\n');
|
|
221
|
-
await ensureConnected();
|
|
222
|
-
const { exitCode } = await $`rsync -az --delete -e ${`ssh -F ${
|
|
275
|
+
await ensureConnected(session);
|
|
276
|
+
const { exitCode } = await $`rsync -az --delete -e ${`ssh -F ${session.sshConfig}`} \
|
|
223
277
|
--exclude .git --exclude node_modules --exclude /.devbox --exclude /ios/Pods --exclude-from=${excludes} \
|
|
224
278
|
./ devbox:project/`.nothrow();
|
|
225
279
|
if (exitCode !== 0) fail(`rsync exited ${exitCode}. Run expo-devbox sync again; it only sends what changed.`);
|
|
@@ -227,37 +281,71 @@ export async function sync() {
|
|
|
227
281
|
|
|
228
282
|
export async function shell(args: string[]) {
|
|
229
283
|
requireProject();
|
|
284
|
+
const session = resolveSession();
|
|
230
285
|
if (args.length === 0) {
|
|
231
|
-
const child = Bun.spawn(['ssh', '-F',
|
|
286
|
+
const child = Bun.spawn(['ssh', '-F', session.sshConfig, '-t', 'devbox', 'cd ~/project && exec $SHELL'],
|
|
232
287
|
{ stdio: ['inherit', 'inherit', 'inherit'] });
|
|
233
288
|
process.exitCode = await child.exited;
|
|
234
289
|
return;
|
|
235
290
|
}
|
|
236
|
-
await ensureConnected();
|
|
237
|
-
const { exitCode } = await $`ssh -F ${
|
|
291
|
+
await ensureConnected(session);
|
|
292
|
+
const { exitCode } = await $`ssh -F ${session.sshConfig} devbox ${`cd ~/project && ${args.join(' ')}`}`.nothrow();
|
|
238
293
|
if (exitCode === SSH_FAILED) console.error(droppedMessage);
|
|
239
294
|
process.exitCode = exitCode;
|
|
240
295
|
}
|
|
241
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}).`);
|
|
307
|
+
}
|
|
308
|
+
|
|
242
309
|
export async function joblog() {
|
|
243
310
|
requireProject();
|
|
244
|
-
const
|
|
245
|
-
|
|
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}`);
|
|
246
314
|
await $`eas workflow:logs ${jobId} --non-interactive`.nothrow();
|
|
247
315
|
}
|
|
248
316
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const runId = savedRun();
|
|
252
|
-
status('working', { msg: 'Ending the devbox session' });
|
|
253
|
-
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();
|
|
254
319
|
// eas workflow:cancel exits 0 even when it fails, so wait for the run's final status instead.
|
|
255
|
-
const result = await $`eas workflow:status ${
|
|
256
|
-
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();
|
|
257
322
|
const ended: Record<number, string> = { 0: 'had already finished', 11: 'had already failed', 12: 'is canceled' };
|
|
258
323
|
if (!(result.exitCode in ended)) {
|
|
259
|
-
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
|
+
}
|
|
260
348
|
}
|
|
261
|
-
|
|
262
|
-
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' });
|
|
263
351
|
}
|