expo-devbox 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brent Vatne
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,180 @@
1
- # Temporary Holding Version
1
+ # expo-devbox
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Build and test your Expo app's native iOS code on an EAS macOS worker instead of your own machine. You, or your coding agent, edit files locally; `expo-devbox build` syncs them to a worker that stays up for the session, runs an incremental Xcode build, launches the app on the worker's iOS Simulator, and reports what happened.
4
+
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
+
7
+ **iOS only for now.** Android support, on an EAS Linux worker with the Android Emulator, will be added to this tool shortly.
8
+
9
+ ```
10
+ $ bunx expo-devbox build
11
+ BUILD SUCCEEDED in 5s
12
+ App console output from this launch:
13
+ LOG [native-crypto] PASS sha384("abc"): cb00753f…
14
+ LOG [native-crypto] DONE 7/7 passed
15
+ Native log from this launch (system libraries excluded):
16
+ 2026-10-24 16:59:04.278 Df myapp[6954:97f5] (Foundation) [native-crypto] module created
17
+ Screenshot copied to .devbox/screenshot.png
18
+ ```
19
+
20
+ > [!NOTE]
21
+ > SSH support for EAS Workflows is coming soon (`eas workflow:run --ssh` and `eas workflow:ssh`). It will cover about half of what this tool does: the SSH server, the Cloudflare tunnel, finding the worker, and ending idle sessions. The other half stays: syncing your files, incremental builds, launching the app and checking its logs, crash reports, `devbox.json`, and the instructions for coding agents. When workflow SSH ships, `expo-devbox` will use it.
22
+
23
+ ## Set it up with your coding agent
24
+
25
+ Paste this into your coding agent, in your Expo app:
26
+
27
+ ```text
28
+ Set up expo-devbox (https://www.npmjs.com/package/expo-devbox) in this Expo app, so that you build and test native iOS code on an EAS macOS worker instead of this machine.
29
+
30
+ 1. Check the requirements: bun 1.3 or later, eas-cli logged in (`eas whoami`), and cloudflared (`brew install cloudflared` if it is missing). If the app has no EAS project ID in its app config, run `eas init`.
31
+ 2. Run `bunx expo-devbox init` in the app's root directory.
32
+ 3. Read the "Native iOS builds on EAS (expo-devbox)" section that init added to AGENTS.md or CLAUDE.md, and follow it from now on.
33
+ 4. If the app logs a line when it is ready or when a self-test passes or fails, set `waitFor` and `failOn` in devbox.json to regular expressions for those lines.
34
+ 5. Ask me before you run `bunx expo-devbox start`: a session bills EAS macOS minutes while it runs. Then run `bunx expo-devbox build`, tell me the result, and run `bunx expo-devbox stop` when we are done.
35
+ ```
36
+
37
+ ## Requirements
38
+
39
+ - An Expo app linked to an EAS project (`eas init`), on a plan with macOS workflow minutes. A session holds one macOS concurrency slot and is billed for as long as it runs. Check your plan's price and job time limits.
40
+ - [Bun](https://bun.sh) 1.3 or later, and `eas-cli`, logged in.
41
+ - `cloudflared`: `brew install cloudflared`.
42
+
43
+ ## Set up
44
+
45
+ In the root of your app:
46
+
47
+ ```sh
48
+ bunx expo-devbox init
49
+ ```
50
+
51
+ `init` does the following:
52
+
53
+ - writes the workflow to `.eas/workflows/ios-devbox.yml`,
54
+ - writes `devbox.json`,
55
+ - adds `.devbox/` to `.gitignore`,
56
+ - adds instructions for coding agents to `AGENTS.md` or `CLAUDE.md`. If neither file exists, it prints them instead; see [Coding agents](#coding-agents).
57
+
58
+ Then:
59
+
60
+ ```sh
61
+ bunx expo-devbox start # start a session: 60 minutes at most, ends after 15 idle minutes
62
+ bunx expo-devbox build # sync, build, launch, check
63
+ bunx expo-devbox stop # end the session
64
+ ```
65
+
66
+ ## Commands
67
+
68
+ | Command | What it does |
69
+ | --- | --- |
70
+ | `start [minutes] [idle-minutes]` | Starts the workflow and waits until SSH works (about a minute). The worker then prepares the project: install, `expo prebuild`, `pod install`, simulator, Metro, and a first build. That takes about three more minutes. |
71
+ | `build` | Syncs your files, builds, launches the app, prints its console and native logs, and copies the screenshot to `.devbox/screenshot.png`. |
72
+ | `build --pods` | Same, after adding or removing native source files or editing a podspec. |
73
+ | `build --prebuild` | Same, after changing `package.json`, the app config or a config plugin. |
74
+ | `ssh [command]` | Runs a command in the project directory on the worker, or opens a shell. |
75
+ | `logs [pattern]` | Searches the app's console output from Metro. |
76
+ | `sync` | Copies your files to the worker without building. |
77
+ | `attach [run-id]` | Reconnects to a running session. `build` reconnects on its own. |
78
+ | `joblog` | Prints the workflow job's log. |
79
+ | `stop` | Cancels the session and waits until EAS confirms that it has ended. |
80
+
81
+ `build` exits non-zero, and says why, when:
82
+
83
+ - the build fails: it prints the compiler errors with file and line,
84
+ - the app is not running after launch: it prints the exception and the crashing frames,
85
+ - the app does not log a line matching `waitFor`, or logs one matching `failOn`.
86
+
87
+ ## devbox.json
88
+
89
+ | Key | Meaning |
90
+ | --- | --- |
91
+ | `waitFor` | A regular expression for a line your app logs when it is ready, for example when its self-test finishes. `build` waits for it, up to `waitSeconds`, and fails if it never appears. Without it, `build` waits 8 seconds. |
92
+ | `failOn` | A regular expression for a line that means failure. `build` exits non-zero if the app logs it. |
93
+ | `waitSeconds` | How long to wait for `waitFor`. The default is 60. |
94
+ | `scheme` | The Xcode scheme, when it is not named after the `.xcworkspace`. |
95
+
96
+ ## Coding agents
97
+
98
+ `init` adds the following to `AGENTS.md` or `CLAUDE.md`. Copy it into your agent's instructions if you use another file:
99
+
100
+ ```markdown
101
+ ## Native iOS builds on EAS (expo-devbox)
102
+
103
+ Compile and test native iOS code on an EAS macOS worker with `bunx expo-devbox`, not on this machine. Do not run `npx expo run:ios` or `xcodebuild` locally.
104
+
105
+ 1. Start a session once: `bunx expo-devbox start`. It bills EAS macOS minutes until `stop`, its time limit (60 minutes by default), or 15 minutes without a connection.
106
+ 2. After each change, run `bunx expo-devbox build`. Add `--pods` after adding or removing native source files or editing a podspec, and `--prebuild` after changing package.json, the app config or a config plugin.
107
+ 3. `build` syncs your files, compiles, launches the app on the worker's iOS Simulator, and prints the app's console and native logs. It exits non-zero on a compile error (with file and line), a crash at launch (with the crashing frames), or a failed check from devbox.json. Read the output, fix the code, and build again. The screenshot is `.devbox/screenshot.png`.
108
+ 4. `bunx expo-devbox ssh '<command>'` runs a command in the project directory on the worker, for example `xcrun simctl list devices booted`.
109
+ 5. When you are done, run `bunx expo-devbox stop`.
110
+ ```
111
+
112
+ To check results without reading pixels, have your app log a line when its checks pass or fail, and set `waitFor` and `failOn`.
113
+
114
+ In a terminal that supports the [Program Status Protocol](https://mitchellh.com/writing/program-status-osc7501) (OSC 7501), such as Ghostty, `start`, `build` and `stop` report their status: working, done, error, or blocked when the run needs approval. The CLI sends these reports only when its stderr is a terminal, so output that an agent captures never contains them.
115
+
116
+ ## How it works
117
+
118
+ The workflow runs one custom job on `macos-large` in the `development` environment:
119
+
120
+ 1. **SSH.** It starts `sshd` as the build user on `127.0.0.1:2222`. Only the key `~/.ssh/eas_devbox`, created by `start`, can log in. SSH sessions get the toolchain variables (`PATH`, Ruby, nvm) but not the job's EAS environment variables or its `EXPO_TOKEN`. The job itself still has them, and the SSH user is the job user, so do not give a session to someone who should not see the development environment.
121
+ 2. **Tunnel.** EAS workers accept no inbound connections, so the worker opens a pinned `cloudflared` [quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/), and Cloudflare relays your connection to it. The job log prints the tunnel host and the worker's SSH host key; the CLI reads them with `eas workflow:logs` and pins the key. Quick tunnels have no uptime guarantee. If the tunnel exits, the worker opens a new one and `build` reconnects.
122
+ 3. **Preparation.** It runs `expo prebuild` (skipped when the project commits `ios/`) and `pod install`, and boots an iPhone simulator. It starts Metro with `CI` unset, because with `CI=true` Metro ignores file changes. Then it runs a first Debug build.
123
+ 4. **Session.** The job stays up until `minutes` after it started, or until no SSH connection has been open for `idle-minutes`. It restarts Metro if Metro stops responding.
124
+
125
+ `sync` sends what git would track. Patterns from `.gitignore` are excluded, so local secrets such as `.env.local` stay local and files that the worker generated are not deleted. `.gitignore` lines that start with `!` are not supported.
126
+
127
+ Steps 1 and 2, and the idle shutdown in step 4, exist because EAS Workflows does not offer SSH yet. Workflow SSH is coming soon and will replace them; see the note at the top.
128
+
129
+ ## Limits
130
+
131
+ - iOS only. Android support will be added shortly.
132
+ - One app per project root. Monorepos with dependencies outside the app directory are not supported.
133
+ - `build --prebuild` reinstalls dependencies with the package manager whose lockfile is present.
134
+ - Private npm packages need their token as an EAS environment variable in the `development` environment.
135
+
136
+ ## Turn off the intro
137
+
138
+ `expo-devbox` and `expo-devbox init` open with an animated wordmark when you run them in a terminal. Set `EXPO_DEVBOX_INTRO=off` to skip it, or set it to `slider`, `decrypt`, `beams`, `snap` or `slide` to pick one.
139
+
140
+ ## Developing expo-devbox
141
+
142
+ The CLI is at the root of this repository: `src/` holds the commands and the worker script, `templates/` holds the workflow that `init` writes, and `test/` holds the unit tests.
143
+
144
+ ```sh
145
+ bun install
146
+ bun run typecheck
147
+ bun test
148
+ bun link && bun install -g . # put this checkout on your PATH as expo-devbox
149
+ ```
150
+
151
+ ### The example app
152
+
153
+ [`example/`](example) is the app to test the CLI against, end to end, on a real EAS worker. It has a local Expo module, [`modules/native-crypto`](example/modules/native-crypto). The module wraps CryptoKit SHA-256, SHA-384, SHA-512 and HMAC-SHA256, and `SecRandomCopyBytes`. On launch, [`App.tsx`](example/App.tsx) checks the module against published test vectors (FIPS 180-2 and RFC 4231). It logs a `[native-crypto] PASS` or `FAIL` line for each check, then `[native-crypto] DONE`. [`devbox.json`](example/devbox.json) makes `build` wait for `DONE` and fail on `FAIL`.
154
+
155
+ The example does not depend on `expo-devbox`. Run the linked CLI from the example's directory:
156
+
157
+ ```sh
158
+ cd example
159
+ bun install
160
+ expo-devbox start
161
+ expo-devbox build
162
+ expo-devbox stop
163
+ ```
164
+
165
+ To use your own EAS account, delete `owner` and `extra.eas` from `example/app.json` and run `eas init` in `example/`.
166
+
167
+ Before a release, run these checks in the example. Edit the app, run `expo-devbox build`, and check the exit code and the message; then revert the edit.
168
+
169
+ | Change | Expected result |
170
+ | --- | --- |
171
+ | None | exit 0, `DONE 7/7 passed`, the `module created` line in the native log |
172
+ | A wrong expected value in `App.tsx` | exit 1, `matching failOn` |
173
+ | A Swift type error | exit 1, the error with file and line |
174
+ | `fatalError()` in the module's `OnCreate` | exit 1, the crash report frames |
175
+ | `build --prebuild` | exit 0 |
176
+ | `.env.local` present, then `sync` | the file is not on the worker |
177
+ | `expo-devbox ssh 'pkill -9 cloudflared'`, then `build` | `build` reconnects on its own, exit 0 |
178
+ | `stop` | `The devbox session is canceled.` |
179
+
180
+ After you change `templates/ios-devbox.yml`, run `expo-devbox init --force --no-agents` in `example/` to update its workflow.
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "expo-devbox",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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
+ "type": "module",
6
+ "bin": {
7
+ "expo-devbox": "src/cli.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "templates"
12
+ ],
13
+ "engines": {
14
+ "bun": ">=1.3"
15
+ },
16
+ "scripts": {
17
+ "typecheck": "tsc --noEmit",
18
+ "test": "bun test"
19
+ },
20
+ "devDependencies": {
21
+ "@types/bun": "^1.4.4",
22
+ "typescript": "^7.0.2"
23
+ },
24
+ "license": "MIT"
25
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bun
2
+ import { init } from './init';
3
+ import { playIntro } from './intro';
4
+ import { attach, build, joblog, logs, shell, start, stop, sync, UserError } from './session';
5
+ import { status } from './status';
6
+
7
+ const HELP = `Build and test an Expo app's native iOS code on an EAS macOS worker,
8
+ from your terminal or a coding agent.
9
+
10
+ Usage: expo-devbox <command>
11
+
12
+ init [--force] [--no-agents] add the devbox workflow to this Expo app
13
+ start [minutes] [idle-minutes] start a session (default 60 and 15); wait until SSH works
14
+ build [--pods|--prebuild] sync, build with Xcode, launch, print logs, copy the screenshot
15
+ ssh [command] run a command in the project on the worker, or open a shell
16
+ logs [pattern] search the app's console output
17
+ sync copy local sources to the worker
18
+ attach [run-id] reconnect to a running session
19
+ joblog print the workflow job's log
20
+ stop cancel the session and wait until it has ended
21
+
22
+ Requires a logged-in eas-cli and cloudflared (brew install cloudflared).
23
+ Docs: https://www.npmjs.com/package/expo-devbox`;
24
+
25
+ const commands: Record<string, (args: string[]) => unknown> = {
26
+ init, start, build, ssh: shell, logs, sync, attach, joblog, stop,
27
+ };
28
+
29
+ const [name, ...args] = process.argv.slice(2);
30
+ if (!name || name === 'help' || name === '--help' || name === '-h') {
31
+ if (process.stdout.isTTY) await playIntro();
32
+ console.log(HELP);
33
+ } else if (name === 'version' || name === '--version' || name === '-v') {
34
+ console.log((await import('../package.json')).version);
35
+ } else if (!commands[name]) {
36
+ console.error(`Unknown command "${name}". Run expo-devbox help to see the commands.`);
37
+ process.exitCode = 1;
38
+ } else {
39
+ try {
40
+ if (name === 'init' && process.stdout.isTTY) await playIntro();
41
+ await commands[name](args);
42
+ } catch (error) {
43
+ status('error', { msg: error instanceof Error ? error.message.split('\n')[0] : String(error) });
44
+ if (error instanceof UserError) {
45
+ console.error(error.message);
46
+ } else if (error && typeof error === 'object' && 'exitCode' in error && 'stderr' in error) {
47
+ // A failed shell command: show what it printed.
48
+ console.error(`${String(error.stderr).trim() || 'A command failed.'} (exit ${error.exitCode})`);
49
+ } else {
50
+ throw error;
51
+ }
52
+ process.exitCode = 1;
53
+ }
54
+ }
package/src/init.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ import { UserError, WORKFLOW } from './session';
5
+
6
+ const TEMPLATE = join(import.meta.dir, '..', 'templates', 'ios-devbox.yml');
7
+
8
+ /** The section `init` adds to AGENTS.md or CLAUDE.md, and prints when neither exists. */
9
+ export const AGENT_INSTRUCTIONS = `## Native iOS builds on EAS (expo-devbox)
10
+
11
+ Compile and test native iOS code on an EAS macOS worker with \`bunx expo-devbox\`, not on this machine. Do not run \`npx expo run:ios\` or \`xcodebuild\` locally.
12
+
13
+ 1. Start a session once: \`bunx expo-devbox start\`. It bills EAS macOS minutes until \`stop\`, its time limit (60 minutes by default), or 15 minutes without a connection.
14
+ 2. After each change, run \`bunx expo-devbox build\`. Add \`--pods\` after adding or removing native source files or editing a podspec, and \`--prebuild\` after changing package.json, the app config or a config plugin.
15
+ 3. \`build\` syncs your files, compiles, launches the app on the worker's iOS Simulator, and prints the app's console and native logs. It exits non-zero on a compile error (with file and line), a crash at launch (with the crashing frames), or a failed check from devbox.json. Read the output, fix the code, and build again. The screenshot is \`.devbox/screenshot.png\`.
16
+ 4. \`bunx expo-devbox ssh '<command>'\` runs a command in the project directory on the worker, for example \`xcrun simctl list devices booted\`.
17
+ 5. When you are done, run \`bunx expo-devbox stop\`.
18
+ `;
19
+
20
+ export function init(args: string[]) {
21
+ const force = args.includes('--force');
22
+ const skipAgents = args.includes('--no-agents');
23
+ if (!existsSync('package.json')) throw new UserError('No package.json here. Run expo-devbox init in the root of your Expo app.');
24
+ const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
25
+ if (!pkg.dependencies?.expo) throw new UserError('This project does not depend on expo. expo-devbox works with Expo apps.');
26
+
27
+ const done: string[] = [];
28
+ if (existsSync(WORKFLOW) && !force) {
29
+ done.push(`Kept the existing ${WORKFLOW} (use --force to replace it).`);
30
+ } else {
31
+ mkdirSync(dirname(WORKFLOW), { recursive: true });
32
+ copyFileSync(TEMPLATE, WORKFLOW);
33
+ done.push(`Wrote ${WORKFLOW}.`);
34
+ }
35
+
36
+ if (!existsSync('devbox.json')) {
37
+ writeFileSync('devbox.json', `${JSON.stringify({ waitFor: '', failOn: '' }, null, 2)}\n`);
38
+ done.push('Wrote devbox.json. Set waitFor and failOn to regular expressions that match lines your app logs, so build waits for them and fails on them.');
39
+ }
40
+
41
+ const gitignore = existsSync('.gitignore') ? readFileSync('.gitignore', 'utf8') : '';
42
+ if (!/^\/?\.devbox\/?$/m.test(gitignore)) {
43
+ appendFileSync('.gitignore', `${gitignore && !gitignore.endsWith('\n') ? '\n' : ''}\n# expo-devbox session state\n.devbox/\n`);
44
+ done.push('Added .devbox/ to .gitignore.');
45
+ }
46
+
47
+ if (!skipAgents) {
48
+ const file = ['AGENTS.md', 'CLAUDE.md'].find(name => existsSync(name));
49
+ if (file && readFileSync(file, 'utf8').includes('expo-devbox')) {
50
+ done.push(`${file} already mentions expo-devbox.`);
51
+ } else if (file) {
52
+ appendFileSync(file, `\n${AGENT_INSTRUCTIONS}`);
53
+ done.push(`Added instructions for coding agents to ${file}.`);
54
+ } else {
55
+ done.push(`No AGENTS.md or CLAUDE.md. Add this to the instructions your coding agent reads:\n\n${AGENT_INSTRUCTIONS}`);
56
+ }
57
+ }
58
+
59
+ const appJson = existsSync('app.json') ? JSON.parse(readFileSync('app.json', 'utf8')) : undefined;
60
+ const linked = appJson?.expo?.extra?.eas?.projectId;
61
+ console.log(done.map(line => `• ${line}`).join('\n'));
62
+ console.log(`
63
+ Next:
64
+ ${linked ? '' : 'eas init link this project to EAS, if it is not linked yet\n '}brew install cloudflared the SSH tunnel client
65
+ bunx expo-devbox start start a session (EAS macOS minutes are billed while it runs)
66
+ bunx expo-devbox build build, launch and check the app on the worker`);
67
+ }
package/src/intro.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The "expo devbox" wordmark the CLI opens with when a person runs it in a
3
+ * terminal, and the five ways it can arrive. One is picked at random each
4
+ * launch (EXPO_DEVBOX_INTRO names one, or `off`). Each intro is a function of
5
+ * progress t (0..1) to the wordmark's two rows, so it can be drawn at any
6
+ * frame rate and tested without a terminal. Adapted from Expo Explore.
7
+ */
8
+ export type Paint = Record<'white' | 'text' | 'dim' | 'faint' | 'amber' | 'green' | 'yellow' | 'blue' | 'red', (text: string) => string>;
9
+ export type Intro = { duration: number; frame: (t: number, paint: Paint) => string[] };
10
+
11
+ const sgr = (code: string) => (text: string) => `\x1b[${code}m${text}\x1b[0m`;
12
+ export const COLOR: Paint = {
13
+ white: sgr('1;97'), text: sgr('97'), dim: sgr('38;5;244'), faint: sgr('38;5;238'), amber: sgr('38;5;214'),
14
+ green: sgr('38;5;114'), yellow: sgr('38;5;221'), blue: sgr('38;5;111'), red: sgr('38;5;203'),
15
+ };
16
+ export const PLAIN: Paint = {
17
+ white: s => s, text: s => s, dim: s => s, faint: s => s, amber: s => s, green: s => s, yellow: s => s, blue: s => s, red: s => s,
18
+ };
19
+
20
+ // A two-row pixel font: one column between letters, three between words.
21
+ const GLYPHS: Record<string, [string, string]> = {
22
+ b: ['█▄▄', '█▄█'], d: ['█▀▄', '█▄▀'], e: ['█▀▀', '██▄'], o: ['█▀█', '█▄█'],
23
+ p: ['█▀█', '█▀▀'], v: ['█ █', '▀▄▀'], x: ['▀▄▀', '█ █'],
24
+ };
25
+ export const WORDMARK: string[] = [0, 1].map(row =>
26
+ 'expo devbox'.split(' ').map(word => [...word].map(letter => GLYPHS[letter]![row]).join(' ')).join(' '));
27
+ const WIDTH = WORDMARK[0]!.length;
28
+ const NOISE = [...'░▒▓▚▞▖▗▘▝◆◇○●■□▪▫'];
29
+
30
+ const ease = (t: number) => 1 - (1 - t) ** 3;
31
+ /** A small seeded random, so an intro plays the same way for the same seed (and in tests). */
32
+ function seeded(seed: number): () => number {
33
+ let state = seed % 2147483647 || 1;
34
+ return () => {
35
+ state = (state * 16807) % 2147483647;
36
+ return state / 2147483647;
37
+ };
38
+ }
39
+ const cells = (fn: (ch: string, row: number, column: number) => string) =>
40
+ WORDMARK.map((row, r) => [...row].map((ch, c) => fn(ch, r, c)).join(''));
41
+
42
+ export function intros(seed = 7): Record<IntroName, Intro> {
43
+ const random = seeded(seed);
44
+ // decrypt: when each letter locks in.
45
+ const lock = WORDMARK.map(row => [...row].map(() => 0.3 + random() * 0.6));
46
+ // snap: letters start in each other's places and each snaps home at its own moment.
47
+ const swaps = WORDMARK.map(row => {
48
+ const map = [...row].map((_, c) => c);
49
+ const letters = map.filter(c => row[c] !== ' ');
50
+ for (let i = 0; i < letters.length * 0.35; i += 1) {
51
+ const a = letters[Math.floor(random() * letters.length)]!;
52
+ const b = letters[Math.floor(random() * letters.length)]!;
53
+ [map[a], map[b]] = [map[b]!, map[a]!];
54
+ }
55
+ return map.map((from, c) => ({ from, at: from === c ? 0 : 0.3 + random() * 0.5 }));
56
+ });
57
+ const noise = () => NOISE[Math.floor(random() * NOISE.length)]!;
58
+ return {
59
+ // A compare slider: the crisp wordmark behind the handle, a faded one ahead of it.
60
+ slider: { duration: 600, frame: (t, paint) => {
61
+ const at = Math.round(ease(t) * WIDTH);
62
+ return cells((ch, _r, c) => c === at && t < 1 ? paint.amber('┃') : c < at ? paint.white(ch) : paint.faint(ch === ' ' ? ' ' : '░'));
63
+ } },
64
+ // Every letter cycles through noise, then locks in with an amber flash.
65
+ decrypt: { duration: 900, frame: (t, paint) => cells((ch, r, c) => {
66
+ if (ch === ' ') return ' ';
67
+ const at = lock[r]![c]!;
68
+ if (t < at) return (t < 0.15 ? paint.faint : paint.blue)(noise());
69
+ return t < at + 0.07 ? paint.amber(ch) : paint.white(ch);
70
+ }) },
71
+ // A diagonal beam of light draws the wordmark as it passes.
72
+ beams: { duration: 700, frame: (t, paint) => {
73
+ const beam = t * (WIDTH + 16) - 6;
74
+ return cells((ch, r, c) => {
75
+ const distance = c + r * 3 - beam;
76
+ return distance > 0 ? ' ' : distance > -3 ? paint.amber(ch === ' ' ? '·' : ch) : distance > -6 ? paint.yellow(ch) : paint.white(ch);
77
+ });
78
+ } },
79
+ // A build fixing itself: misplaced letters (amber) snap home (green), then settle.
80
+ snap: { duration: 900, frame: (t, paint) => cells((ch, r, c) => {
81
+ const { from, at } = swaps[r]![c]!;
82
+ const original = WORDMARK[r]![from]!;
83
+ if (t < 0.12) return paint.faint(original === ' ' ? ' ' : '░');
84
+ if (at === 0) return paint.text(ch);
85
+ if (t < at) return paint.amber(original);
86
+ return t < at + 0.1 ? paint.green(ch) : paint.white(ch);
87
+ }) },
88
+ // Letters glide in from the right one after another, gray to white.
89
+ slide: { duration: 800, frame: (t, paint) => WORDMARK.map(row => {
90
+ const out = Array.from({ length: WIDTH + 24 }, () => ' ');
91
+ for (let c = 0; c < WIDTH; c += 1) {
92
+ if (row[c] === ' ') continue;
93
+ const local = Math.min(1, Math.max(0, (t - (c / WIDTH) * 0.5) / 0.5));
94
+ if (local === 0) continue;
95
+ out[c + Math.round((1 - ease(local)) * 24)] = (local < 1 ? paint.dim : paint.white)(row[c]!);
96
+ }
97
+ return out.join('').trimEnd();
98
+ }) },
99
+ };
100
+ }
101
+
102
+ export const INTRO_NAMES = ['slider', 'decrypt', 'beams', 'snap', 'slide'] as const;
103
+ export type IntroName = (typeof INTRO_NAMES)[number];
104
+
105
+ /** EXPO_DEVBOX_INTRO when it names one (or `off`), otherwise a random one. */
106
+ export function pickIntro(environment: NodeJS.ProcessEnv, random = Math.random): IntroName | undefined {
107
+ const chosen = environment.EXPO_DEVBOX_INTRO?.trim().toLowerCase();
108
+ if (chosen === 'off' || chosen === 'none') return undefined;
109
+ if (chosen && (INTRO_NAMES as readonly string[]).includes(chosen)) return chosen as IntroName;
110
+ return INTRO_NAMES[Math.floor(random() * INTRO_NAMES.length)];
111
+ }
112
+
113
+ /**
114
+ * Plays an intro in place and leaves the finished wordmark on screen. Only for
115
+ * a person at a color terminal: agents and CI get plain output.
116
+ */
117
+ export async function playIntro(output: NodeJS.WriteStream = process.stdout): Promise<void> {
118
+ const color = output.isTTY && !process.env.NO_COLOR && !process.env.CI;
119
+ const paint = color ? COLOR : PLAIN;
120
+ // A width of 0 means the terminal did not report one.
121
+ const name = color && (output.columns || 80) >= WIDTH + 6 ? pickIntro(process.env) : undefined;
122
+ const indent = (rows: string[]) => rows.map(row => ` ${row}`);
123
+ if (!name) {
124
+ output.write(`\n${indent(WORDMARK.map(row => paint.white(row))).join('\n')}\n\n`);
125
+ return;
126
+ }
127
+ const intro = intros(Date.now())[name];
128
+ const restore = () => output.write('\x1b[?25h');
129
+ process.once('exit', restore);
130
+ output.write(`\x1b[?25l\n${'\n'.repeat(WORDMARK.length)}`);
131
+ const start = Date.now();
132
+ for (;;) {
133
+ const t = Math.min(1, (Date.now() - start) / intro.duration);
134
+ output.write(`\x1b[${WORDMARK.length}A${indent(intro.frame(t, paint)).map(row => `\r${row}\x1b[K`).join('\n')}\n`);
135
+ if (t === 1) break;
136
+ await Bun.sleep(16);
137
+ }
138
+ restore();
139
+ process.removeListener('exit', restore);
140
+ output.write('\n');
141
+ }
package/src/session.ts ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The session commands: start a devbox workflow run, connect to its worker
3
+ * over SSH through the Cloudflare tunnel it opens, sync sources, build, and
4
+ * stop it. Run from the project root.
5
+ */
6
+ import { $ } from 'bun';
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join, resolve } from 'node:path';
10
+
11
+ import { status } from './status';
12
+
13
+ export const WORKFLOW = '.eas/workflows/ios-devbox.yml';
14
+ const STATE = '.devbox';
15
+ const SSH_CONFIG = join(STATE, 'ssh_config');
16
+ const RUN_ID = join(STATE, 'run_id');
17
+ const KEY = join(homedir(), '.ssh', 'eas_devbox');
18
+ const WORKER_SCRIPT = join(import.meta.dir, 'worker-build.ts');
19
+ const ACTIVE = new Set(['NEW', 'WAITING', 'IN_PROGRESS', 'ACTION_REQUIRED']);
20
+
21
+ /** An error whose message is meant for the person or agent running the command. */
22
+ export class UserError extends Error {}
23
+ const fail = (message: string): never => {
24
+ throw new UserError(message);
25
+ };
26
+ const log = (message: string) => console.error(message);
27
+ const sleep = (seconds: number) => Bun.sleep(seconds * 1000);
28
+ /** Quotes a value for the worker's shell. */
29
+ const quote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
30
+
31
+ type Run = { status: string; jobs?: { id: string }[] };
32
+
33
+ async function viewRun(runId: string): Promise<Run | undefined> {
34
+ const result = await $`eas workflow:view ${runId} --json --non-interactive`.quiet().nothrow();
35
+ try {
36
+ return result.exitCode === 0 ? JSON.parse(result.stdout.toString()) : undefined;
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ /** Fails when the run has ended. A run that cannot be read (a network error) counts as running. */
43
+ async function requireActive(runId: string): Promise<Run | undefined> {
44
+ const run = await viewRun(runId);
45
+ if (run && !ACTIVE.has(run.status)) {
46
+ fail(`The devbox session ended with status ${run.status}. See: eas workflow:view ${runId}`);
47
+ }
48
+ if (run?.status === 'ACTION_REQUIRED') {
49
+ status('blocked', { kind: 'permission', msg: 'The devbox workflow run needs approval on expo.dev' });
50
+ }
51
+ return run;
52
+ }
53
+
54
+ function savedRun(): string {
55
+ if (!existsSync(RUN_ID)) fail('No devbox session. Start one with: expo-devbox start');
56
+ return readFileSync(RUN_ID, 'utf8').trim();
57
+ }
58
+
59
+ function requireProject() {
60
+ if (!existsSync(WORKFLOW)) fail(`${WORKFLOW} is missing. Run expo-devbox init in the project root first.`);
61
+ mkdirSync(STATE, { recursive: true });
62
+ }
63
+
64
+ function ssh(remoteCommand: string) {
65
+ return $`ssh -F ${SSH_CONFIG} devbox ${remoteCommand}`;
66
+ }
67
+
68
+ /**
69
+ * Reads the tunnel host, user and host key from the job log and writes an SSH
70
+ * config for them. The last DEVBOX_SSH_HOST wins, because the worker starts a
71
+ * new tunnel, with a new host, if the old one exits. A host that does not
72
+ * answer is skipped until a newer one appears in the log.
73
+ */
74
+ async function connect(runId: string): Promise<void> {
75
+ log(`Waiting for the worker to open its tunnel (run ${runId})...`);
76
+ status('working', { msg: 'Waiting for the EAS worker' });
77
+ let deadHost = '';
78
+ const deadline = Date.now() + 15 * 60_000;
79
+ while (Date.now() < deadline) {
80
+ const jobId = (await requireActive(runId))?.jobs?.[0]?.id;
81
+ if (jobId) {
82
+ const logs = (await $`eas workflow:logs ${jobId} --non-interactive`.quiet().nothrow()).stdout.toString();
83
+ const last = (pattern: RegExp) => [...logs.matchAll(pattern)].at(-1)?.[1];
84
+ const host = last(/DEVBOX_SSH_HOST=([a-z0-9.-]+)/g);
85
+ const user = last(/DEVBOX_SSH_USER=([A-Za-z0-9_-]+)/g);
86
+ const hostKey = last(/DEVBOX_SSH_HOSTKEY=(ssh-ed25519 [A-Za-z0-9+/=]+)/g);
87
+ if (host && user && hostKey && host !== deadHost) {
88
+ writeSshConfig(host, user, hostKey);
89
+ let error = '';
90
+ // A new quick tunnel can take a few seconds to resolve in DNS.
91
+ for (let attempt = 0; attempt < 8; attempt += 1) {
92
+ const result = await $`ssh -F ${SSH_CONFIG} devbox true`.quiet().nothrow();
93
+ if (result.exitCode === 0) {
94
+ log(`Connected to ${user}@${host}.`);
95
+ return;
96
+ }
97
+ error = result.stderr.toString().trim().split('\n').at(-1) ?? '';
98
+ await sleep(5);
99
+ }
100
+ log(`${host} does not answer (${error}). Waiting for the worker to open a new tunnel...`);
101
+ deadHost = host;
102
+ }
103
+ }
104
+ await sleep(10);
105
+ }
106
+ fail(`Could not reach the worker for 15 minutes. See: eas workflow:view ${runId}`);
107
+ }
108
+
109
+ function writeSshConfig(host: string, user: string, hostKey: string) {
110
+ writeFileSync(join(STATE, 'known_hosts'), `${host} ${hostKey}\n`);
111
+ writeFileSync(SSH_CONFIG, `Host devbox
112
+ HostName ${host}
113
+ User ${user}
114
+ ProxyCommand cloudflared access ssh --hostname %h
115
+ IdentityFile ${KEY}
116
+ IdentitiesOnly yes
117
+ StrictHostKeyChecking yes
118
+ UserKnownHostsFile ${resolve(STATE, 'known_hosts')}
119
+ ConnectTimeout 20
120
+ ServerAliveInterval 30
121
+ ControlMaster auto
122
+ ControlPath ~/.ssh/cm-devbox-%C
123
+ ControlPersist 5m
124
+ `);
125
+ }
126
+
127
+ /** Waits for the worker to finish preparing the project. Reconnects if the worker is unreachable. */
128
+ async function waitUntilReady(runId: string) {
129
+ const deadline = Date.now() + 20 * 60_000;
130
+ while (Date.now() < deadline) {
131
+ const result = await ssh('test -f ~/.devbox/ready').quiet().nothrow();
132
+ if (result.exitCode === 0) return;
133
+ if (result.exitCode === 255) {
134
+ await connect(runId);
135
+ continue;
136
+ }
137
+ await requireActive(runId);
138
+ log('Waiting for the worker to finish preparing the project...');
139
+ status('working', { msg: 'The EAS worker is preparing the project' });
140
+ await sleep(15);
141
+ }
142
+ fail('The worker is not ready after 20 minutes. See: expo-devbox joblog');
143
+ }
144
+
145
+ export async function start(args: string[]) {
146
+ requireProject();
147
+ if (!Bun.which('cloudflared')) fail('cloudflared is not installed. Install it with: brew install cloudflared');
148
+ if (existsSync(RUN_ID)) {
149
+ const previous = readFileSync(RUN_ID, 'utf8').trim();
150
+ const run = await viewRun(previous);
151
+ if (run && ACTIVE.has(run.status)) {
152
+ fail(`A devbox session is already running (run ${previous}). Use expo-devbox attach or expo-devbox stop.`);
153
+ }
154
+ }
155
+ const [minutes = '60', idleMinutes = '15'] = args;
156
+ if (!/^\d+$/.test(minutes) || !/^\d+$/.test(idleMinutes)) fail('Usage: expo-devbox start [minutes] [idle-minutes]');
157
+ if (!existsSync(KEY)) await $`ssh-keygen -q -t ed25519 -N ${''} -C eas-devbox -f ${KEY}`;
158
+ const publicKey = readFileSync(`${KEY}.pub`, 'utf8').trim();
159
+
160
+ status('working', { msg: 'Starting an EAS macOS worker' });
161
+ const result = await $`eas workflow:run ${WORKFLOW} --non-interactive --json -F ${`ssh_public_key=${publicKey}`} \
162
+ -F ${`minutes=${minutes}`} -F ${`idle_minutes=${idleMinutes}`}`.env({ ...process.env, EAS_NO_VCS: '1' }).quiet().nothrow();
163
+ if (result.exitCode !== 0) fail(`eas workflow:run failed:\n${result.stderr.toString().trim()}`);
164
+ const runId: string = JSON.parse(result.stdout.toString()).id;
165
+ writeFileSync(RUN_ID, runId);
166
+ log(`Started run ${runId}: ${minutes} minutes at most, ending after ${idleMinutes} idle minutes.`);
167
+
168
+ // Cancel the run if this command fails or is interrupted, so it does not bill with nobody connected.
169
+ const cancel = () => $`eas workflow:cancel ${runId} --non-interactive`.quiet().nothrow();
170
+ const onInterrupt = async () => {
171
+ log(`Cancelling run ${runId}.`);
172
+ await cancel();
173
+ status('idle');
174
+ process.exit(130);
175
+ };
176
+ process.once('SIGINT', onInterrupt);
177
+ try {
178
+ await connect(runId);
179
+ } catch (error) {
180
+ log(`Cancelling run ${runId}.`);
181
+ await cancel();
182
+ throw error;
183
+ } finally {
184
+ process.removeListener('SIGINT', onInterrupt);
185
+ }
186
+ log(`The worker is still preparing the project. 'expo-devbox build' waits for it.`);
187
+ status('done', { msg: 'Connected to the EAS worker' });
188
+ }
189
+
190
+ export async function attach(args: string[]) {
191
+ requireProject();
192
+ const runId = args[0] ?? savedRun();
193
+ writeFileSync(RUN_ID, runId);
194
+ await connect(runId);
195
+ status('done', { msg: 'Connected to the EAS worker' });
196
+ }
197
+
198
+ /**
199
+ * Sends what git would track. .gitignore patterns also protect worker-only
200
+ * files (such as a generated ios/) from --delete. rsync cannot express
201
+ * gitignore's `!` re-includes, so those lines are dropped.
202
+ */
203
+ export async function sync() {
204
+ requireProject();
205
+ const gitignore = existsSync('.gitignore')
206
+ ? readFileSync('.gitignore', 'utf8').split('\n').filter(line => !/^\s*!/.test(line))
207
+ : [];
208
+ const ignored = (await $`git ls-files -oi --exclude-standard --directory`.quiet().nothrow()).stdout.toString()
209
+ .split('\n').filter(Boolean).map(path => `/${path}`);
210
+ const excludes = join(STATE, 'rsync-excludes');
211
+ writeFileSync(excludes, [...gitignore, ...ignored].join('\n') + '\n');
212
+ await $`rsync -az --delete -e ${`ssh -F ${SSH_CONFIG}`} --exclude .git --exclude node_modules --exclude /.devbox \
213
+ --exclude /ios/Pods --exclude-from=${excludes} ./ devbox:project/`;
214
+ }
215
+
216
+ export async function build(args: string[]) {
217
+ requireProject();
218
+ const [option] = args;
219
+ if (option && option !== '--pods' && option !== '--prebuild') fail('Usage: expo-devbox build [--pods|--prebuild]');
220
+ const runId = savedRun();
221
+ status('working', { msg: 'Waiting for the EAS worker' });
222
+ await waitUntilReady(runId);
223
+ status('working', { msg: 'Syncing sources to the EAS worker' });
224
+ await sync();
225
+ await $`scp -q -F ${SSH_CONFIG} ${WORKER_SCRIPT} devbox:.devbox/worker-build.ts`;
226
+
227
+ status('working', { msg: option ? `Building on the EAS worker (${option.slice(2)})` : 'Building on the EAS worker' });
228
+ const screenshot = join(STATE, 'screenshot.png');
229
+ rmSync(screenshot, { force: true });
230
+ const result = await ssh(`cd ~/project && bun ~/.devbox/worker-build.ts ${option ?? ''}`).nothrow();
231
+ if ((await $`scp -q -F ${SSH_CONFIG} devbox:.devbox/screenshot.png ${screenshot}`.quiet().nothrow()).exitCode === 0) {
232
+ console.log(`Screenshot copied to ${screenshot}`);
233
+ }
234
+ const summary = result.stdout.toString().match(/BUILD (SUCCEEDED|FAILED)[^\n]*/)?.[0];
235
+ if (result.exitCode === 0) {
236
+ status('done', { msg: summary ?? 'Build succeeded' });
237
+ } else {
238
+ status('error', { msg: summary?.startsWith('BUILD FAILED') ? summary : 'The app failed its checks; see the output' });
239
+ }
240
+ process.exitCode = result.exitCode;
241
+ }
242
+
243
+ export async function logs(args: string[]) {
244
+ requireProject();
245
+ await ssh(`grep -a -E ${quote(args[0] ?? '.')} ~/.devbox/metro.log | tail -50`).nothrow();
246
+ }
247
+
248
+ export async function shell(args: string[]) {
249
+ requireProject();
250
+ if (args.length === 0) {
251
+ const child = Bun.spawn(['ssh', '-F', SSH_CONFIG, '-t', 'devbox', 'cd ~/project && exec $SHELL'],
252
+ { stdio: ['inherit', 'inherit', 'inherit'] });
253
+ process.exitCode = await child.exited;
254
+ return;
255
+ }
256
+ process.exitCode = (await ssh(`cd ~/project && ${args.join(' ')}`).nothrow()).exitCode;
257
+ }
258
+
259
+ export async function joblog() {
260
+ requireProject();
261
+ const jobId = (await viewRun(savedRun()))?.jobs?.[0]?.id;
262
+ if (!jobId) fail('Could not find the job of the devbox run. See: eas workflow:view ' + savedRun());
263
+ await $`eas workflow:logs ${jobId} --non-interactive`.nothrow();
264
+ }
265
+
266
+ export async function stop() {
267
+ requireProject();
268
+ const runId = savedRun();
269
+ status('working', { msg: 'Ending the devbox session' });
270
+ await $`eas workflow:cancel ${runId} --non-interactive`.quiet().nothrow();
271
+ // eas workflow:cancel exits 0 even when it fails, so wait for the run's final status instead.
272
+ const result = await $`eas workflow:status ${runId} --wait --non-interactive`.quiet().nothrow();
273
+ await $`ssh -F ${SSH_CONFIG} -O exit devbox`.quiet().nothrow();
274
+ const ended: Record<number, string> = { 0: 'had already finished', 11: 'had already failed', 12: 'is canceled' };
275
+ if (!(result.exitCode in ended)) {
276
+ fail(`Could not confirm that run ${runId} ended (eas workflow:status exited ${result.exitCode}). Check: eas workflow:view ${runId}`);
277
+ }
278
+ console.log(`The devbox session ${ended[result.exitCode]}.`);
279
+ status('done', { msg: 'Devbox session ended' });
280
+ }
package/src/status.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Reports what the CLI is doing to the terminal with the Program Status
3
+ * Protocol (OSC 7501): https://www.superlogical.com/rex/docs/build/program-status
4
+ * Terminals that do not implement it ignore the sequence. Reports go to stderr,
5
+ * and only when stderr is a terminal, so captured output (an agent, CI, a pipe)
6
+ * never contains them.
7
+ */
8
+ export type State = 'idle' | 'working' | 'done' | 'blocked' | 'error' | 'clear';
9
+ export type Report = { msg?: string; progress?: number; kind?: 'permission' | 'question' | 'auth' };
10
+
11
+ const MAX_MSG_BYTES = 2048;
12
+
13
+ /** Builds the escape sequence for a report. */
14
+ export function formatStatus(state: State, report: Report = {}): string {
15
+ const pairs = [`state=${state}`, 'app=expo-devbox'];
16
+ if (report.kind && state === 'blocked') pairs.push(`kind=${report.kind}`);
17
+ if (report.progress !== undefined) pairs.push(`progress=${Math.max(0, Math.min(100, Math.round(report.progress)))}`);
18
+ if (report.msg) {
19
+ // One line of text, without control characters, within the spec's size limit.
20
+ let bytes = Buffer.from(report.msg.replace(/[\x00-\x1f\x7f]+/g, ' ').trim());
21
+ if (bytes.length > MAX_MSG_BYTES) bytes = bytes.subarray(0, MAX_MSG_BYTES);
22
+ pairs.push(`msg=${bytes.toString('base64')}`);
23
+ }
24
+ return `\x1b]7501;${pairs.join(':')}\x1b\\`;
25
+ }
26
+
27
+ export function status(state: State, report: Report = {}): void {
28
+ if (process.stderr.isTTY) process.stderr.write(formatStatus(state, report));
29
+ }
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Runs on the devbox worker, in the project directory. Builds the iOS app with
4
+ * Xcode, installs it on the devbox simulator, launches it, and prints its logs.
5
+ * Exits non-zero when the build fails, the app is not running after launch, or
6
+ * the checks in devbox.json fail. `expo-devbox build` copies this file to the
7
+ * worker, so it must not import anything but Bun and Node built-ins.
8
+ *
9
+ * worker-build.ts incremental build
10
+ * worker-build.ts --pods run `pod install` first
11
+ * worker-build.ts --prebuild install dependencies, run `expo prebuild` and `pod install` first
12
+ */
13
+ import { $ } from 'bun';
14
+ import { appendFileSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { basename, join } from 'node:path';
17
+
18
+ type Config = { waitFor?: string; failOn?: string; waitSeconds?: number; scheme?: string };
19
+
20
+ const state = join(homedir(), '.devbox');
21
+ const config: Config = existsSync('devbox.json') ? JSON.parse(readFileSync('devbox.json', 'utf8')) : {};
22
+ const waitSeconds = config.waitSeconds ?? (config.waitFor ? 60 : 8);
23
+ const prepareLog = join(state, 'prepare.log');
24
+
25
+ /** Runs a setup command with its output in prepare.log, which is printed only if the command fails. */
26
+ async function prepare(label: string, command: () => Promise<{ exitCode: number; stdout: Buffer; stderr: Buffer }>) {
27
+ console.log(`Running: ${label}`);
28
+ const result = await command();
29
+ appendFileSync(prepareLog, `$ ${label}\n${result.stdout}${result.stderr}`);
30
+ if (result.exitCode !== 0) {
31
+ console.log(readFileSync(prepareLog, 'utf8').trimEnd().split('\n').slice(-30).join('\n'));
32
+ console.log(`FAILED: ${label}. Full log: ${prepareLog}`);
33
+ process.exit(1);
34
+ }
35
+ }
36
+ const podInstall = () => prepare('pod install', () => $`pod install`.cwd('ios').quiet().nothrow());
37
+
38
+ writeFileSync(prepareLog, '');
39
+ const flag = process.argv[2];
40
+ if (flag === '--prebuild') {
41
+ const manager = existsSync('bun.lock') || existsSync('bun.lockb') ? 'bun'
42
+ : existsSync('pnpm-lock.yaml') ? 'pnpm' : existsSync('yarn.lock') ? 'yarn' : 'npm';
43
+ await prepare(`${manager} install`, () => $`${manager} install`.quiet().nothrow());
44
+ if (existsSync(join(state, 'cng'))) {
45
+ await prepare('expo prebuild', () => $`npx expo prebuild --platform ios --no-install`.quiet().nothrow());
46
+ }
47
+ await podInstall();
48
+ } else if (flag === '--pods') {
49
+ await podInstall();
50
+ } else if (flag) {
51
+ console.log(`Unknown option ${flag}. Use --pods or --prebuild.`);
52
+ process.exit(1);
53
+ }
54
+
55
+ const workspace = readdirSync('ios').find(name => name.endsWith('.xcworkspace'));
56
+ if (!workspace) {
57
+ console.log('No .xcworkspace in ios/. Run the build again with --prebuild.');
58
+ process.exit(1);
59
+ }
60
+ const scheme = config.scheme || basename(workspace, '.xcworkspace');
61
+ const udid = readFileSync(join(state, 'sim'), 'utf8').trim();
62
+ const derived = join(state, 'DerivedData');
63
+ const buildLog = join(state, 'xcodebuild.log');
64
+ const screenshot = join(state, 'screenshot.png');
65
+ rmSync(screenshot, { force: true });
66
+
67
+ const start = Date.now();
68
+ const seconds = () => Math.round((Date.now() - start) / 1000);
69
+ const build = await $`xcodebuild -workspace ${`ios/${workspace}`} -scheme ${scheme} -configuration Debug -sdk iphonesimulator \
70
+ -destination ${`platform=iOS Simulator,id=${udid}`} -derivedDataPath ${derived} build &> ${buildLog}`.nothrow();
71
+ if (build.exitCode !== 0) {
72
+ const lines = readFileSync(buildLog, 'utf8').split('\n');
73
+ const errors = [...new Set(lines.filter(line => /(error|fatal error):/.test(line)))].slice(0, 40);
74
+ console.log((errors.length ? errors : lines.slice(-30)).join('\n'));
75
+ console.log(`BUILD FAILED after ${seconds()}s. Full log: ${buildLog}`);
76
+ process.exit(1);
77
+ }
78
+ console.log(`BUILD SUCCEEDED in ${seconds()}s`);
79
+
80
+ const metroUp = await fetch('http://localhost:8081/status').then(r => r.ok, () => false);
81
+ if (!metroUp) {
82
+ console.log('Metro is not running. The worker restarts it within a minute; run the build again then.');
83
+ process.exit(1);
84
+ }
85
+
86
+ const products = join(derived, 'Build/Products/Debug-iphonesimulator');
87
+ const app = join(products, readdirSync(products).find(name => name.endsWith('.app'))!);
88
+ const plist = join(app, 'Info.plist');
89
+ const plistValue = async (key: string) => (await $`/usr/libexec/PlistBuddy -c ${`Print :${key}`} ${plist}`.text()).trim();
90
+ const bundleId = await plistValue('CFBundleIdentifier');
91
+ const executable = await plistValue('CFBundleExecutable');
92
+ const metroLog = join(state, 'metro.log');
93
+ const nativeLog = join(state, 'native.log');
94
+ const metroStart = readFileSync(metroLog).length;
95
+
96
+ await $`xcrun simctl install ${udid} ${app}`;
97
+ // Messages logged by code inside the app bundle, plus NSLog (sent from
98
+ // Foundation, with no subsystem); not the system libraries' own logging.
99
+ // `subsystem == ""` never matches, so "no subsystem" is written as no dot.
100
+ const predicate = `process == "${executable}" AND (senderImagePath CONTAINS "/${executable}.app/" `
101
+ + `OR (senderImagePath ENDSWITH "/Foundation" AND NOT subsystem CONTAINS "."))`;
102
+ const logStream = Bun.spawn(['xcrun', 'simctl', 'spawn', udid, 'log', 'stream', '--style', 'compact', '--predicate', predicate],
103
+ { stdout: Bun.file(nativeLog), stderr: 'ignore' });
104
+ await $`xcrun simctl launch --terminate-running-process ${udid} ${bundleId}`.quiet();
105
+
106
+ const appOutput = () => readFileSync(metroLog).subarray(metroStart).toString()
107
+ .split('\n').filter(line => /^ (LOG|WARN|ERROR) /.test(line)).join('\n');
108
+ // Simulator apps run as processes of the host.
109
+ const appRunning = async () => (await $`pgrep -f ${`/${executable}.app/${executable}`}`.quiet().nothrow()).exitCode === 0;
110
+ const matches = (pattern: string | undefined) => !!pattern && new RegExp(pattern, 'm').test(appOutput());
111
+
112
+ for (let i = 0; i < waitSeconds; i += 1) {
113
+ if (matches(config.waitFor)) break;
114
+ if (config.waitFor && i > 3 && !(await appRunning())) break;
115
+ await Bun.sleep(1000);
116
+ }
117
+ if ((await $`xcrun simctl io ${udid} screenshot ${screenshot}`.quiet().nothrow()).exitCode !== 0) {
118
+ console.log('Could not take a screenshot.');
119
+ }
120
+ logStream.kill();
121
+ await logStream.exited;
122
+
123
+ console.log('App console output from this launch:');
124
+ console.log(appOutput() || '(none)');
125
+ console.log('Native log from this launch (system libraries excluded):');
126
+ const native = readFileSync(nativeLog, 'utf8').split('\n')
127
+ .filter(line => line && !/^(Filtering the log data|Timestamp |getpwuid_r |Child process terminated)/.test(line));
128
+ console.log(native.slice(-40).join('\n') || '(none)');
129
+
130
+ let status = 0;
131
+ if (!(await appRunning())) {
132
+ console.log('The app is not running. It may have crashed at launch.');
133
+ await printCrashReport(executable, statSync(plist).mtimeMs);
134
+ status = 1;
135
+ }
136
+ if (config.waitFor && !matches(config.waitFor)) {
137
+ console.log(`The app did not log a line matching waitFor (${config.waitFor}).`);
138
+ status = 1;
139
+ }
140
+ if (matches(config.failOn)) {
141
+ console.log(`The app logged a line matching failOn (${config.failOn}).`);
142
+ status = 1;
143
+ }
144
+ process.exit(status);
145
+
146
+ /**
147
+ * Prints the exception and the crashing thread's frames from the newest crash
148
+ * report written since the build. macOS writes the report a few seconds after
149
+ * the crash, so this waits for it.
150
+ */
151
+ async function printCrashReport(executable: string, since: number) {
152
+ const dir = join(homedir(), 'Library/Logs/DiagnosticReports');
153
+ const newest = () => (existsSync(dir) ? readdirSync(dir) : [])
154
+ .filter(name => name.startsWith(executable) && name.endsWith('.ips'))
155
+ .map(name => ({ path: join(dir, name), time: statSync(join(dir, name)).mtimeMs }))
156
+ .filter(report => report.time > since)
157
+ .sort((a, b) => b.time - a.time)[0];
158
+ let report = newest();
159
+ for (let i = 0; !report && i < 10; i += 1) {
160
+ await Bun.sleep(1000);
161
+ report = newest();
162
+ }
163
+ if (!report) {
164
+ console.log('No crash report appeared within 10 seconds.');
165
+ return;
166
+ }
167
+ console.log(`Crash report ${report.path}:`);
168
+ // An .ips file is a JSON header line followed by the JSON report.
169
+ const text = readFileSync(report.path, 'utf8');
170
+ try {
171
+ const crash = JSON.parse(text.slice(text.indexOf('\n') + 1));
172
+ console.log(`${crash.exception?.type} (${crash.exception?.signal}), ${crash.termination?.indicator ?? ''}`);
173
+ const thread = crash.threads?.find((t: { triggered?: boolean }) => t.triggered);
174
+ for (const frame of thread?.frames?.slice(0, 15) ?? []) {
175
+ const where = frame.sourceFile ? ` (${frame.sourceFile}:${frame.sourceLine})` : '';
176
+ console.log(` ${crash.usedImages?.[frame.imageIndex]?.name ?? '?'} ${frame.symbol ?? '?'}${where}`);
177
+ }
178
+ } catch {
179
+ console.log(text.split('\n').slice(0, 40).join('\n'));
180
+ }
181
+ }
@@ -0,0 +1,189 @@
1
+ # A macOS worker that stays up so you, or a coding agent, can build and test the
2
+ # iOS app with Xcode over SSH. Written by `expo-devbox init`; start a session
3
+ # with `bunx expo-devbox start`. https://www.npmjs.com/package/expo-devbox
4
+ name: iOS devbox
5
+
6
+ on:
7
+ workflow_dispatch:
8
+ inputs:
9
+ ssh_public_key:
10
+ type: string
11
+ required: true
12
+ description: OpenSSH public key that may log in to the worker.
13
+ minutes:
14
+ type: number
15
+ description: Maximum session length in minutes, setup included.
16
+ default: 60
17
+ idle_minutes:
18
+ type: number
19
+ description: End the session after this many minutes with no SSH connection.
20
+ default: 15
21
+
22
+ jobs:
23
+ devbox:
24
+ name: iOS devbox
25
+ runs_on: macos-large
26
+ # Custom jobs default to the production environment and its variables.
27
+ environment: development
28
+ env:
29
+ SSH_PUBLIC_KEY: ${{ inputs.ssh_public_key }}
30
+ steps:
31
+ - uses: eas/checkout
32
+
33
+ # sshd runs as the build user on localhost only. A Cloudflare quick tunnel
34
+ # (no account needed) exposes it; clients connect with
35
+ # `cloudflared access ssh`. Only the key from the input can log in.
36
+ - name: Start SSH
37
+ run: |
38
+ dir="$HOME/.devbox"
39
+ mkdir -p "$dir" ~/.ssh && chmod 700 ~/.ssh
40
+ echo $(( $(date +%s) + ${{ inputs.minutes }} * 60 )) > "$dir/deadline"
41
+ ln -sfn "$PWD" ~/project
42
+
43
+ printf '%s\n' "$SSH_PUBLIC_KEY" > ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
44
+ if ! ssh-keygen -lf ~/.ssh/authorized_keys; then
45
+ echo "The ssh_public_key input is not an OpenSSH public key. Received: $SSH_PUBLIC_KEY"
46
+ exit 1
47
+ fi
48
+ ssh-keygen -q -t ed25519 -N '' -f "$dir/host_key"
49
+
50
+ # SSH sessions get only the toolchain variables, not the job's EAS
51
+ # variables or its EXPO_TOKEN.
52
+ echo "Job environment variables: $(env | cut -d= -f1 | sort | tr '\n' ' ')"
53
+ env | grep -E '^(PATH|LANG|LC_[A-Z]+|DEVELOPER_DIR|NVM_[A-Z_]+|GEM_[A-Z_]+|RUBY[A-Z_]*|BUNDLE_[A-Z_]+)=' > ~/.ssh/environment
54
+ chmod 600 ~/.ssh/environment
55
+ echo "SSH session variables: $(cut -d= -f1 ~/.ssh/environment | tr '\n' ' ')"
56
+
57
+ cat > "$dir/sshd_config" <<EOF
58
+ Port 2222
59
+ ListenAddress 127.0.0.1
60
+ HostKey $dir/host_key
61
+ PidFile $dir/sshd.pid
62
+ AuthorizedKeysFile .ssh/authorized_keys
63
+ PasswordAuthentication no
64
+ KbdInteractiveAuthentication no
65
+ UsePAM no
66
+ StrictModes no
67
+ PermitUserEnvironment yes
68
+ Subsystem sftp /usr/libexec/sftp-server
69
+ LogLevel VERBOSE
70
+ EOF
71
+ /usr/sbin/sshd -f "$dir/sshd_config" -E "$dir/sshd.log"
72
+
73
+ # Log in once with a throwaway key so an sshd problem fails here, with its log, instead of at the client.
74
+ ssh-keygen -q -t ed25519 -N '' -f "$dir/selftest_key"
75
+ cp ~/.ssh/authorized_keys "$dir/authorized_keys.orig"
76
+ cat "$dir/selftest_key.pub" >> ~/.ssh/authorized_keys
77
+ if ssh -p 2222 -i "$dir/selftest_key" -o IdentitiesOnly=yes -o BatchMode=yes \
78
+ -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null 127.0.0.1 'echo SSH self-test passed'; then
79
+ selftest=ok
80
+ fi
81
+ cp "$dir/authorized_keys.orig" ~/.ssh/authorized_keys
82
+ if [ "${selftest:-}" != ok ]; then
83
+ echo "SSH self-test failed. sshd log:"
84
+ cat "$dir/sshd.log"
85
+ exit 1
86
+ fi
87
+
88
+ curl -fsSL -o "$dir/cloudflared.tgz" \
89
+ https://github.com/cloudflare/cloudflared/releases/download/2026.10.3/cloudflared-darwin-arm64.tgz
90
+ echo "1165d14b6f6360a3b8c283d7d1100024d6ec7cd9bc9e32519e96150645f3ec55 $dir/cloudflared.tgz" | shasum -a 256 -c -
91
+ tar -xzf "$dir/cloudflared.tgz" -C "$dir"
92
+
93
+ # The keep-alive step reruns this if the tunnel exits. A new tunnel gets a new host.
94
+ cat > "$dir/tunnel.sh" <<'EOF'
95
+ #!/bin/bash
96
+ dir="$HOME/.devbox"
97
+ "$dir/cloudflared" tunnel --no-autoupdate --protocol http2 --url ssh://localhost:2222 > "$dir/cloudflared.log" 2>&1 &
98
+ echo $! > "$dir/cloudflared.pid"
99
+ for _ in $(seq 60); do
100
+ host=$(grep -oE '[a-z0-9-]+\.trycloudflare\.com' "$dir/cloudflared.log" | head -1 || true)
101
+ if [ -n "$host" ]; then
102
+ echo "DEVBOX_SSH_HOST=$host"
103
+ exit 0
104
+ fi
105
+ sleep 2
106
+ done
107
+ cat "$dir/cloudflared.log"
108
+ echo "The Cloudflare tunnel did not start."
109
+ exit 1
110
+ EOF
111
+ chmod +x "$dir/tunnel.sh"
112
+ "$dir/tunnel.sh"
113
+ echo "DEVBOX_SSH_USER=$(whoami)"
114
+ echo "DEVBOX_SSH_HOSTKEY=$(cut -d' ' -f1,2 "$dir/host_key.pub")"
115
+
116
+ - uses: eas/install_node_modules
117
+
118
+ - name: Prebuild and install pods
119
+ run: |
120
+ if [ -d ios ]; then
121
+ echo "Using the committed ios directory."
122
+ else
123
+ npx expo prebuild --platform ios --no-install
124
+ touch ~/.devbox/cng
125
+ fi
126
+ cd ios && pod install
127
+
128
+ - name: Boot simulator
129
+ run: |
130
+ udid=$(xcrun simctl list devices available --json | node -e '
131
+ const { devices } = JSON.parse(require("fs").readFileSync(0, "utf8"));
132
+ const version = (runtime) => (runtime.match(/iOS-(\d+)-(\d+)/) ?? [0, 0, 0]).slice(1).map(Number);
133
+ const iphones = Object.keys(devices)
134
+ .filter((r) => r.includes("iOS"))
135
+ .sort((a, b) => version(b)[0] - version(a)[0] || version(b)[1] - version(a)[1])
136
+ .flatMap((r) => devices[r])
137
+ .filter((d) => d.name.startsWith("iPhone"));
138
+ const d = iphones.find((d) => /^iPhone \d+ Pro$/.test(d.name)) ?? iphones[0];
139
+ console.log(d.udid);')
140
+ xcrun simctl boot "$udid"
141
+ xcrun simctl bootstatus "$udid" -b
142
+ echo "$udid" > ~/.devbox/sim
143
+ xcrun simctl list devices booted
144
+
145
+ - name: Keep the worker up
146
+ run: |
147
+ dir="$HOME/.devbox"
148
+ start_metro() {
149
+ # EAS sets CI=true, which makes Metro ignore file changes.
150
+ env -u CI npx expo start --localhost > "$dir/metro.log" 2>&1 < /dev/null &
151
+ }
152
+ start_metro
153
+ # The same xcodebuild arguments as `expo-devbox build`, so its first build is incremental.
154
+ workspace=$(ls -d ios/*.xcworkspace | head -1)
155
+ scheme=$(node -p 'try { require("./devbox.json").scheme || "" } catch { "" }')
156
+ xcodebuild -workspace "$workspace" -scheme "${scheme:-$(basename "$workspace" .xcworkspace)}" \
157
+ -configuration Debug -sdk iphonesimulator -destination "platform=iOS Simulator,id=$(cat "$dir/sim")" \
158
+ -derivedDataPath "$dir/DerivedData" build > "$dir/xcodebuild.log" 2>&1 \
159
+ || echo "The warm-up build failed. Run expo-devbox build to see why."
160
+ touch "$dir/ready"
161
+ echo "Devbox ready at ~/project."
162
+
163
+ deadline=$(cat "$dir/deadline")
164
+ idle_limit=$(( ${{ inputs.idle_minutes }} * 60 ))
165
+ last_active=$(date +%s)
166
+ while [ "$(date +%s)" -lt "$deadline" ]; do
167
+ sleep 20
168
+ now=$(date +%s)
169
+ if netstat -an -p tcp | grep -q '127\.0\.0\.1\.2222 .*ESTABLISHED'; then
170
+ last_active=$now
171
+ elif [ $(( now - last_active )) -ge "$idle_limit" ]; then
172
+ echo "No SSH connection for ${{ inputs.idle_minutes }} minutes. Ending the session."
173
+ exit 0
174
+ fi
175
+ if ! kill -0 "$(cat "$dir/cloudflared.pid")" 2> /dev/null; then
176
+ echo "The tunnel exited. Starting a new one."
177
+ "$dir/tunnel.sh" || true
178
+ fi
179
+ # Two misses in a row, so a Metro that is still starting is left alone.
180
+ if curl -sf http://localhost:8081/status > /dev/null; then
181
+ metro_misses=0
182
+ elif [ $(( metro_misses = ${metro_misses:-0} + 1 )) -ge 2 ]; then
183
+ echo "Metro is not responding. Restarting it."
184
+ pkill -f "expo start" || true
185
+ start_metro
186
+ metro_misses=0
187
+ fi
188
+ done
189
+ echo "Reached the ${{ inputs.minutes }}-minute limit. Ending the session."