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 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 that has your app prebuilt, its pods installed, an iOS Simulator booted, Metro running, and a first build done. Then you, or your agent, copy files over with `expo-devbox sync` and run commands on it with `expo-devbox ssh`.
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
- ## Requirements
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
- ## Set up
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]` | Adds the devbox workflow to the app, as in [Set up](#set-up). `--force` replaces an existing workflow; `--no-agents` leaves `AGENTS.md` and `CLAUDE.md` alone. |
43
- | `start [minutes] [idle-minutes]` | Starts a session and waits until the worker is ready, about 4 minutes. The session ends after `minutes` (default 60) or after `idle-minutes` with no SSH connection (default 15). |
44
- | `sync` | Copies your files to the worker: what git would track, so `.gitignore` applies. Run it after each change. |
45
- | `ssh ['command']` | Runs a command in the app's directory on the worker, or opens a shell. If the connection drops while the command runs, it exits 255 and does not run the command again. |
46
- | `attach [run-id]` | Reconnects to a running session. `sync` and `ssh` also reconnect on their own when the worker's tunnel has moved. |
47
- | `joblog` | Prints the workflow job's log. |
48
- | `stop` | Ends the session and waits until EAS confirms that it has ended. |
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 these commands and the steps in [On the worker](#on-the-worker).
54
+ `expo-devbox help` prints all of this and the steps below.
51
55
 
52
- ## On the worker
53
-
54
- After each change, run `expo-devbox sync`, then run these with `expo-devbox ssh '<command>'`. The `DEVBOX_*` variables are set in every SSH session.
56
+ <details>
57
+ <summary id="sessions"><strong>Several sessions</strong></summary>
55
58
 
56
- For example:
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 sync
60
- $ bunx expo-devbox ssh 'set -o pipefail; xcodebuild … build 2>&1 | tee ~/.devbox/build.log | grep -E ": (fatal )?error:|\*\* BUILD" | sort -u'
61
- ** BUILD SUCCEEDED **
62
- $ bunx expo-devbox ssh 'tail -n 3 ~/.devbox/metro.log'
63
- LOG [native-crypto] PASS sha384("abc"): cb00753f…
64
- LOG [native-crypto] DONE 7/7 passed
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
- **Build.** Prints errors with file and line, then `** BUILD SUCCEEDED **` or `** BUILD FAILED **`, and exits non-zero on failure. The full log is `~/.devbox/build.log`. After the worker's first build, an incremental build takes a few seconds.
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
- ```sh
70
- set -o pipefail; xcodebuild -workspace "$DEVBOX_WORKSPACE" -scheme "$DEVBOX_SCHEME" -configuration Debug -destination "id=$DEVBOX_SIMULATOR" -derivedDataPath "$DEVBOX_DERIVED_DATA" build 2>&1 | tee ~/.devbox/build.log | grep -E ": (fatal )?error:|\*\* BUILD" | sort -u
71
- ```
69
+ </details>
70
+
71
+ ## On the worker
72
72
 
73
- **Install and launch** the app on the simulator:
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
- xcrun simctl install booted "$DEVBOX_APP" && xcrun simctl launch --terminate-running-process booted "$DEVBOX_BUNDLE_ID"
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
- **JavaScript console output and errors**, from Metro:
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
- ```sh
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
- ```sh
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
- ```sh
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
- ```sh
101
- xcrun simctl io booted screenshot ~/.devbox/screenshot.png
102
- scp -F .devbox/ssh_config devbox:.devbox/screenshot.png .devbox/
103
- ```
104
-
105
- **After adding or removing native files, or editing a podspec:**
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
- ```sh
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
- ```sh
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
- ### The variables
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
- ## Coding agents
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. When the user is done with the devbox, run `bunx expo-devbox stop`.
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
- ## How it works
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 `ssh` reconnect.
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 (prebuild, pods, simulator, Metro, a first build), syncing your files safely, the variables and commands for building and running the app, and the instructions for coding agents. Steps 1 and 2, and the idle shutdown in step 4, exist because EAS Workflows does not offer SSH yet. When workflow SSH ships, `expo-devbox` will use it.
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
- ## Limits
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
- ## Turn off the intro
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
- ## Developing expo-devbox
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
- ### The example app
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
- [`example/`](example) is the app to test the CLI against, end to end, on a real EAS worker. It has a local Expo module, [`modules/native-crypto`](example/modules/native-crypto). The module wraps CryptoKit SHA-256, SHA-384, SHA-512 and HMAC-SHA256, and `SecRandomCopyBytes`. On launch, [`App.tsx`](example/App.tsx) checks the module against published test vectors (FIPS 180-2 and RFC 4231). It logs a `[native-crypto] PASS` or `FAIL` line for each check, then `[native-crypto] DONE`, and `NSLog`s `[native-crypto] module created` from Swift.
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
- | `stop` | `The devbox session is canceled.` |
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
- After you change `templates/ios-devbox.yml`, run `expo-devbox init --force --no-agents` in `example/` to update its workflow.
213
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-devbox",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Build and test an Expo app's native iOS code on an EAS macOS worker, from your terminal or a coding agent.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/args.ts ADDED
@@ -0,0 +1,49 @@
1
+ /** Command-line parsing, kept apart from the commands so it can be tested. */
2
+ export type Parsed = { command?: string; args: string[]; session?: string };
3
+
4
+ /**
5
+ * Splits argv into the command, its arguments and a --session value.
6
+ * `--session <id>`, `--session=<id>` and `-s <id>` are read before the command
7
+ * and before the command's first argument, so a remote command passed to `ssh`
8
+ * is never parsed: `expo-devbox ssh -s 01a1 'ls --session'`.
9
+ */
10
+ export function parseArgs(argv: string[]): Parsed {
11
+ let session: string | undefined;
12
+ let command: string | undefined;
13
+ const args: string[] = [];
14
+ let i = 0;
15
+ const readFlags = () => {
16
+ while (i < argv.length) {
17
+ const arg = argv[i]!;
18
+ if (arg === '--session' || arg === '-s') {
19
+ session = argv[i + 1];
20
+ i += 2;
21
+ } else if (arg.startsWith('--session=')) {
22
+ session = arg.slice('--session='.length);
23
+ i += 1;
24
+ } else {
25
+ return;
26
+ }
27
+ }
28
+ };
29
+ readFlags();
30
+ if (i < argv.length) command = argv[i++];
31
+ readFlags();
32
+ args.push(...argv.slice(i));
33
+ return { command, args, ...(session ? { session } : {}) };
34
+ }
35
+
36
+ /** Finds the one session ID that starts with the prefix. */
37
+ export function matchSession(ids: string[], prefix: string): string {
38
+ const matches = ids.filter(id => id.startsWith(prefix));
39
+ if (matches.length === 1) return matches[0]!;
40
+ if (matches.length === 0) {
41
+ throw new Error(`No devbox session in this directory starts with "${prefix}". See: expo-devbox sessions`);
42
+ }
43
+ throw new Error(`"${prefix}" matches ${matches.length} sessions: ${matches.map(shortId).join(', ')}. Use more of the ID.`);
44
+ }
45
+
46
+ /** The part of a session (workflow run) ID that is shown: unique per millisecond of start time. */
47
+ export function shortId(id: string): string {
48
+ return id.slice(0, 13);
49
+ }
package/src/cli.ts CHANGED
@@ -1,22 +1,30 @@
1
1
  #!/usr/bin/env bun
2
2
  import { init } from './init';
3
3
  import { playIntro } from './intro';
4
- import { attach, joblog, shell, start, stop, sync, UserError } from './session';
4
+ import { parseArgs } from './args';
5
+ import { attach, joblog, pull, selectSession, sessions, shell, start, stop, sync, UserError } from './session';
5
6
  import { status } from './status';
6
7
 
7
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
- Session:
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 the worker is ready (about 4 minutes).
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
- attach [run-id] reconnect to a running session
18
- joblog print the workflow job's log
19
- stop end the session and wait until EAS confirms it ended
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 (then, on your machine: scp -F .devbox/ssh_config devbox:.devbox/screenshot.png .devbox/):
45
- xcrun simctl io booted screenshot ~/.devbox/screenshot.png
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> = { init, start, sync, ssh: shell, attach, joblog, stop };
65
+ const commands: Record<string, (args: string[]) => unknown> = {
66
+ init, start, sync, ssh: shell, pull, sessions, attach, joblog, stop,
67
+ };
58
68
 
59
- const [name, ...args] = process.argv.slice(2);
69
+ const { command: name, args, session } = parseArgs(process.argv.slice(2));
70
+ selectSession(session ?? process.env.EXPO_DEVBOX_SESSION);
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. When the user is done with the devbox, run \`bunx expo-devbox stop\`.
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 SSH_CONFIG = join(STATE, 'ssh_config');
16
- const RUN_ID = join(STATE, 'run_id');
20
+ const SESSIONS = join(STATE, 'sessions');
21
+ const CURRENT = join(STATE, 'current');
17
22
  const KEY = join(homedir(), '.ssh', 'eas_devbox');
18
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(runId: string): Promise<Run | undefined> {
43
- const run = await viewRun(runId);
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
- fail(`The devbox session ended with status ${run.status}. Start a new one with: expo-devbox start`);
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(runId: string): Promise<void> {
70
- log(`Waiting for the worker to open its tunnel (run ${runId})...`);
103
+ async function connect(session: Session): Promise<void> {
104
+ log(`Waiting for the worker to open its tunnel (session ${shortId(session.id)})...`);
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(runId))?.jobs?.[0]?.id;
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 ${SSH_CONFIG} devbox true`.quiet().nothrow();
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
- writeFileSync(join(STATE, 'known_hosts'), `${host} ${hostKey}\n`);
106
- writeFileSync(SSH_CONFIG, `Host devbox
138
+ function writeSshConfig(session: Session, host: string, user: string, hostKey: string) {
139
+ const knownHosts = join(session.dir, 'known_hosts');
140
+ writeFileSync(knownHosts, `${host} ${hostKey}\n`);
141
+ writeFileSync(session.sshConfig, `Host devbox
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(STATE, 'known_hosts')}
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 ${SSH_CONFIG} devbox true`.quiet().nothrow();
129
- if (probe.exitCode === SSH_FAILED) await connect(savedRun());
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(runId: string) {
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 ${SSH_CONFIG} devbox ${'test -f ~/.devbox/ready'}`.quiet().nothrow();
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(runId);
177
+ await connect(session);
143
178
  continue;
144
179
  }
145
- await requireActive(runId);
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 runId: string = JSON.parse(result.stdout.toString()).id;
171
- writeFileSync(RUN_ID, runId);
172
- log(`Started run ${runId}: ${minutes} minutes at most, ending after ${idleMinutes} minutes without an SSH connection.`);
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 = () => $`eas workflow:cancel ${runId} --non-interactive`.quiet().nothrow();
204
+ const cancel = async () => {
205
+ await $`eas workflow:cancel ${session.id} --non-interactive`.quiet().nothrow();
206
+ forget(session);
207
+ };
176
208
  const onInterrupt = async () => {
177
- log(`Cancelling run ${runId}.`);
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(runId);
185
- await waitUntilReady(runId);
216
+ await connect(session);
217
+ await waitUntilReady(session);
186
218
  } catch (error) {
187
- log(`Cancelling run ${runId}.`);
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
- console.log('The worker is ready. Run expo-devbox help and follow "On the worker" to build and run the app.');
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 runId = args[0] ?? savedRun();
200
- writeFileSync(RUN_ID, runId);
201
- await connect(runId);
202
- await waitUntilReady(runId);
203
- console.log('The worker is ready.');
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(STATE, 'rsync-excludes');
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 ${SSH_CONFIG}`} \
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', SSH_CONFIG, '-t', 'devbox', 'cd ~/project && exec $SHELL'],
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 ${SSH_CONFIG} devbox ${`cd ~/project && ${args.join(' ')}`}`.nothrow();
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 jobId = (await viewRun(savedRun()))?.jobs?.[0]?.id;
245
- if (!jobId) fail(`Could not find the job of the devbox run. See: eas workflow:view ${savedRun()}`);
311
+ const session = resolveSession();
312
+ const jobId = (await viewRun(session.id))?.jobs?.[0]?.id;
313
+ if (!jobId) fail(`Could not find the job of session ${shortId(session.id)}. See: eas workflow:view ${session.id}`);
246
314
  await $`eas workflow:logs ${jobId} --non-interactive`.nothrow();
247
315
  }
248
316
 
249
- export async function stop() {
250
- requireProject();
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 ${runId} --wait --non-interactive`.quiet().nothrow();
256
- await $`ssh -F ${SSH_CONFIG} -O exit devbox`.quiet().nothrow();
320
+ const result = await $`eas workflow:status ${session.id} --wait --non-interactive`.quiet().nothrow();
321
+ await $`ssh -F ${session.sshConfig} -O exit devbox`.quiet().nothrow();
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 run ${runId} ended (eas workflow:status exited ${result.exitCode}). Check: eas workflow:view ${runId}`);
324
+ fail(`Could not confirm that session ${shortId(session.id)} ended (eas workflow:status exited ${result.exitCode}). Check: eas workflow:view ${session.id}`);
325
+ }
326
+ forget(session);
327
+ return `The devbox session ${shortId(session.id)} ${ended[result.exitCode]}.`;
328
+ }
329
+
330
+ export async function stop(args: string[]) {
331
+ requireProject();
332
+ const all = args.includes('--all');
333
+ const targets = all ? sessionIds().map(sessionAt) : [resolveSession()];
334
+ if (targets.length === 0) {
335
+ console.log('No devbox sessions in this directory.');
336
+ return;
337
+ }
338
+ status('working', { msg: all ? 'Ending all devbox sessions' : 'Ending the devbox session' });
339
+ // With --all, one session that cannot be confirmed does not stop the others from ending.
340
+ const failures: string[] = [];
341
+ for (const session of targets) {
342
+ try {
343
+ console.log(await stopOne(session));
344
+ } catch (error) {
345
+ if (!(error instanceof UserError)) throw error;
346
+ failures.push(error.message);
347
+ }
260
348
  }
261
- console.log(`The devbox session ${ended[result.exitCode]}.`);
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
  }