expo-devbox 0.0.0-stage → 0.1.0
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 +160 -2
- package/package.json +23 -4
- package/src/cli.ts +54 -0
- package/src/init.ts +67 -0
- package/src/intro.ts +141 -0
- package/src/session.ts +280 -0
- package/src/status.ts +29 -0
- package/src/worker-build.ts +181 -0
- package/templates/ios-devbox.yml +189 -0
package/README.md
CHANGED
|
@@ -1,3 +1,161 @@
|
|
|
1
|
-
#
|
|
1
|
+
# expo-devbox
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
```
|
|
8
|
+
$ bunx expo-devbox build
|
|
9
|
+
BUILD SUCCEEDED in 5s
|
|
10
|
+
App console output from this launch:
|
|
11
|
+
LOG [native-crypto] PASS sha384("abc"): cb00753f…
|
|
12
|
+
LOG [native-crypto] DONE 7/7 passed
|
|
13
|
+
Native log from this launch (system libraries excluded):
|
|
14
|
+
2026-10-24 16:59:04.278 Df myapp[6954:97f5] (Foundation) [native-crypto] module created
|
|
15
|
+
Screenshot copied to .devbox/screenshot.png
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- 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.
|
|
21
|
+
- [Bun](https://bun.sh) 1.3 or later, and `eas-cli`, logged in.
|
|
22
|
+
- `cloudflared`: `brew install cloudflared`.
|
|
23
|
+
|
|
24
|
+
## Set up
|
|
25
|
+
|
|
26
|
+
In the root of your app:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
bunx expo-devbox init
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`init` does the following:
|
|
33
|
+
|
|
34
|
+
- writes the workflow to `.eas/workflows/ios-devbox.yml`,
|
|
35
|
+
- writes `devbox.json`,
|
|
36
|
+
- adds `.devbox/` to `.gitignore`,
|
|
37
|
+
- adds instructions for coding agents to `AGENTS.md` or `CLAUDE.md`. If neither file exists, it prints them instead; see [Coding agents](#coding-agents).
|
|
38
|
+
|
|
39
|
+
Then:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
bunx expo-devbox start # start a session: 60 minutes at most, ends after 15 idle minutes
|
|
43
|
+
bunx expo-devbox build # sync, build, launch, check
|
|
44
|
+
bunx expo-devbox stop # end the session
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Commands
|
|
48
|
+
|
|
49
|
+
| Command | What it does |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `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. |
|
|
52
|
+
| `build` | Syncs your files, builds, launches the app, prints its console and native logs, and copies the screenshot to `.devbox/screenshot.png`. |
|
|
53
|
+
| `build --pods` | Same, after adding or removing native source files or editing a podspec. |
|
|
54
|
+
| `build --prebuild` | Same, after changing `package.json`, the app config or a config plugin. |
|
|
55
|
+
| `ssh [command]` | Runs a command in the project directory on the worker, or opens a shell. |
|
|
56
|
+
| `logs [pattern]` | Searches the app's console output from Metro. |
|
|
57
|
+
| `sync` | Copies your files to the worker without building. |
|
|
58
|
+
| `attach [run-id]` | Reconnects to a running session. `build` reconnects on its own. |
|
|
59
|
+
| `joblog` | Prints the workflow job's log. |
|
|
60
|
+
| `stop` | Cancels the session and waits until EAS confirms that it has ended. |
|
|
61
|
+
|
|
62
|
+
`build` exits non-zero, and says why, when:
|
|
63
|
+
|
|
64
|
+
- the build fails: it prints the compiler errors with file and line,
|
|
65
|
+
- the app is not running after launch: it prints the exception and the crashing frames,
|
|
66
|
+
- the app does not log a line matching `waitFor`, or logs one matching `failOn`.
|
|
67
|
+
|
|
68
|
+
## devbox.json
|
|
69
|
+
|
|
70
|
+
| Key | Meaning |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| `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. |
|
|
73
|
+
| `failOn` | A regular expression for a line that means failure. `build` exits non-zero if the app logs it. |
|
|
74
|
+
| `waitSeconds` | How long to wait for `waitFor`. The default is 60. |
|
|
75
|
+
| `scheme` | The Xcode scheme, when it is not named after the `.xcworkspace`. |
|
|
76
|
+
|
|
77
|
+
## Coding agents
|
|
78
|
+
|
|
79
|
+
`init` adds the following to `AGENTS.md` or `CLAUDE.md`. Copy it into your agent's instructions if you use another file:
|
|
80
|
+
|
|
81
|
+
```markdown
|
|
82
|
+
## Native iOS builds on EAS (expo-devbox)
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
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.
|
|
88
|
+
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`.
|
|
89
|
+
4. `bunx expo-devbox ssh '<command>'` runs a command in the project directory on the worker, for example `xcrun simctl list devices booted`.
|
|
90
|
+
5. When you are done, run `bunx expo-devbox stop`.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
To check results without reading pixels, have your app log a line when its checks pass or fail, and set `waitFor` and `failOn`.
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
## How it works
|
|
98
|
+
|
|
99
|
+
The workflow runs one custom job on `macos-large` in the `development` environment:
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
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.
|
|
103
|
+
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.
|
|
104
|
+
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.
|
|
105
|
+
|
|
106
|
+
`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.
|
|
107
|
+
|
|
108
|
+
EAS has built-in workflow SSH (`eas workflow:run --ssh`) that is not available in production yet. When it is, it will replace the SSH and tunnel steps.
|
|
109
|
+
|
|
110
|
+
## Limits
|
|
111
|
+
|
|
112
|
+
- iOS only.
|
|
113
|
+
- One app per project root. Monorepos with dependencies outside the app directory are not supported.
|
|
114
|
+
- `build --prebuild` reinstalls dependencies with the package manager whose lockfile is present.
|
|
115
|
+
- Private npm packages need their token as an EAS environment variable in the `development` environment.
|
|
116
|
+
|
|
117
|
+
## Turn off the intro
|
|
118
|
+
|
|
119
|
+
`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.
|
|
120
|
+
|
|
121
|
+
## Developing expo-devbox
|
|
122
|
+
|
|
123
|
+
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.
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
bun install
|
|
127
|
+
bun run typecheck
|
|
128
|
+
bun test
|
|
129
|
+
bun link && bun install -g . # put this checkout on your PATH as expo-devbox
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### The example app
|
|
133
|
+
|
|
134
|
+
[`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`.
|
|
135
|
+
|
|
136
|
+
The example does not depend on `expo-devbox`. Run the linked CLI from the example's directory:
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
cd example
|
|
140
|
+
bun install
|
|
141
|
+
expo-devbox start
|
|
142
|
+
expo-devbox build
|
|
143
|
+
expo-devbox stop
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
To use your own EAS account, delete `owner` and `extra.eas` from `example/app.json` and run `eas init` in `example/`.
|
|
147
|
+
|
|
148
|
+
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.
|
|
149
|
+
|
|
150
|
+
| Change | Expected result |
|
|
151
|
+
| --- | --- |
|
|
152
|
+
| None | exit 0, `DONE 7/7 passed`, the `module created` line in the native log |
|
|
153
|
+
| A wrong expected value in `App.tsx` | exit 1, `matching failOn` |
|
|
154
|
+
| A Swift type error | exit 1, the error with file and line |
|
|
155
|
+
| `fatalError()` in the module's `OnCreate` | exit 1, the crash report frames |
|
|
156
|
+
| `build --prebuild` | exit 0 |
|
|
157
|
+
| `.env.local` present, then `sync` | the file is not on the worker |
|
|
158
|
+
| `expo-devbox ssh 'pkill -9 cloudflared'`, then `build` | `build` reconnects on its own, exit 0 |
|
|
159
|
+
| `stop` | `The devbox session is canceled.` |
|
|
160
|
+
|
|
161
|
+
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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."
|