native-sim 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 ADDED
@@ -0,0 +1,395 @@
1
+ # native-sim
2
+
3
+ Push an Expo app to GitHub, build it on a GitHub-hosted macOS runner, and stream
4
+ the live iOS Simulator back to your browser through [`@expo/serve-sim`](https://github.com/expo/serve-sim).
5
+
6
+ ```sh
7
+ cd my-expo-app
8
+ native-sim up --public --minutes 45
9
+ ```
10
+
11
+ ```
12
+ › Preparing repository
13
+ ✓ committed on main
14
+ ✓ pushed to bidah/my-expo-app
15
+ › Dispatching build
16
+ ✓ run https://github.com/bidah/my-expo-app/actions/runs/1234567
17
+ ⠹ Build and install the app (5/12)
18
+
19
+ ● Simulator is live
20
+ https://calm-river-1234.trycloudflare.com/?k=…
21
+ ```
22
+
23
+ ## What it does
24
+
25
+ 1. `git init` / commit / push, creating the GitHub repo if there isn't one.
26
+ 2. Dispatches `.github/workflows/native-sim.yml` with a session id and a one-time access key.
27
+ 3. The runner boots a simulator, `expo prebuild` + `xcodebuild`s a Release build, installs and launches it.
28
+ 4. `serve-sim --detach` captures the simulator; a small auth gate fronts it; `cloudflared` opens a tunnel.
29
+ 5. The runner publishes the URL as a **commit status** — the one GitHub surface readable *while a job is still running* (logs and artifacts only land after it ends).
30
+ 6. The CLI polls for that status and opens your browser.
31
+
32
+ For how the stream reaches your browser and what keeps it alive, see
33
+ [docs/how-the-connection-works.md](docs/how-the-connection-works.md). To run an
34
+ already-built app from a repo containing no source, see
35
+ [docs/prebuilt-app-flow.md](docs/prebuilt-app-flow.md).
36
+
37
+ ## Commands
38
+
39
+ | Command | |
40
+ |---|---|
41
+ | `native-sim up` | Push, build, stream. Runs `init` implicitly. |
42
+ | `native-sim init` | Write the workflow and auth gate into the project. |
43
+ | `native-sim status` | Current session, run state, stream URL. |
44
+ | `native-sim down` | Cancel the run — the stream and the runner stop together. |
45
+ | `native-sim doctor` | Check prerequisites. |
46
+ | `native-sim upload` | Upload a simulator build to R2, print a presigned URL. |
47
+ | `native-sim r2` | Configure R2 credentials (`--status` to inspect). |
48
+ | `native-sim turn` | Store TURN credentials as repo secrets, for `--transport webrtc`. |
49
+
50
+ ### `native-sim up` options
51
+
52
+ | Flag | Default | |
53
+ |---|---|---|
54
+ | `--minutes <n>` | `30` | Stream lifetime. Max 350; GitHub kills hosted jobs at 6h. |
55
+ | `--device <name>` | `iPhone 17 Pro` | Falls back to the newest available iPhone. |
56
+ | `--mode <build\|app\|go>` | `build` | `app` installs a prebuilt archive and skips compiling entirely. `go` uses Expo Go — fast, but only for projects without custom native code. |
57
+ | `--scheme <name>` | workspace name | Xcode scheme. |
58
+ | `--runner <label>` | `macos-26` | Must be arm64: `serve-sim` ships arm64-only native binaries. `macos-26`/`macos-latest` and `macos-15` are arm64; the `-large`/`-intel` labels are x64 and will not work. |
59
+ | `--codec <c>` | `mjpeg` | `mjpeg` is the only codec that works: `/stream.avcc` (h264) returns `200` and emits zero bytes on GitHub's macOS runners. |
60
+ | `--max-dimension <px>` | `900` | Caps captured width/height. Native (~1206×2622) is far more than the browser displays, and pixels cost encode CPU *and* bandwidth quadratically. `0` keeps native. |
61
+ | `--fps <n>` | `30` | MJPEG frame rate. serve-sim's own default is 60. |
62
+ | `--quality <n>` | `0.7` | MJPEG quality, 0.05–1. |
63
+ | `--agent` | off | Also expose an [agent-device](#driving-the-simulator-from-a-coding-agent) proxy so a coding agent can drive the simulator. |
64
+ | `--export` | off | Download the built `.app` archive (`--out <dir>`). |
65
+ | `--no-cache` | off | Force a full native rebuild. The build cache is **on by default**; this is an escape hatch for verifying a clean build or a suspected stale cache. |
66
+ | `--public` | off | Create the repo public. **Public repos get unlimited free Actions minutes.** |
67
+ | `--repo <name>` | directory name | Repo name when creating one. |
68
+ | `--no-open` | | Don't open the browser. |
69
+
70
+ ## Build caching (on by default)
71
+
72
+ The native build is the whole cost: **28.4 minutes** measured cold. native-sim caches the
73
+ built `.app` keyed on [`@expo/fingerprint`](https://www.npmjs.com/package/@expo/fingerprint),
74
+ which hashes the inputs that affect the *native* build — dependencies, config plugins,
75
+ native `app.json` fields — and deliberately ignores your application JS.
76
+
77
+ On a cache hit the build is skipped entirely and `expo export:embed` rewrites
78
+ `main.jsbundle` inside the restored `.app`, so you get the cached native shell running
79
+ your current JavaScript.
80
+
81
+ | | Stream URL | App on screen |
82
+ |---|---|---|
83
+ | Cold cache | ~5 min | ~33 min |
84
+ | Warm cache | ~3 min | **7 min (measured)** |
85
+
86
+ Change JS → cache hit. Change a native dependency or config plugin → fingerprint moves
87
+ → full rebuild → new cache entry. Caches are 10 GB/repo and evict after 7 days unused.
88
+
89
+ Requires no configuration and no agent involvement — it is part of the workflow.
90
+
91
+ ## Running an already-built app
92
+
93
+ If you already have a simulator build, native-sim can skip compiling entirely:
94
+
95
+ ```sh
96
+ native-sim up --app https://expo.dev/artifacts/eas/xxxx.tar.gz --public
97
+ ```
98
+
99
+ **Your source is never pushed.** The repo holds only `.github/workflows/native-sim.yml`;
100
+ the runner downloads the archive, installs it, and streams it. This is the way to use a
101
+ free public repo without publishing private code — the code simply never goes there.
102
+
103
+ Requirements:
104
+
105
+ - **It must be a *simulator* build**, not a device build. A device `.ipa` cannot run on a
106
+ simulator; the workflow checks `CFBundleSupportedPlatforms` and fails with a clear
107
+ message rather than a confusing `simctl` error. With EAS, that means a profile with
108
+ `ios.simulator: true`.
109
+ - **The URL must be reachable by the runner.** A local path cannot work — the runner
110
+ cannot reach your machine. EAS build URLs, release assets, or any `https://` link are
111
+ fine. Signed URLs expire, so fetch a fresh one per session.
112
+ - `.tar.gz` or `.zip` containing the `.app`.
113
+
114
+ ### Hosting your own builds
115
+
116
+ By default a local build is uploaded as an asset on a **draft release of the same repo the
117
+ workflow runs in**, and the runner fetches it with the job's own `GITHUB_TOKEN`:
118
+
119
+ ```sh
120
+ native-sim up --app-file ./MyApp.app # upload + run in one step
121
+ native-sim upload ./MyApp.app # just upload
122
+ native-sim up --app-release MyApp.app.tar.gz # run something already uploaded
123
+ ```
124
+
125
+ This needs no third-party account and no credentials file. It works for **public and private
126
+ repos alike**: a private repo's release assets are readable only by people who can read the
127
+ repo, and there is no signed URL to expire mid-session.
128
+
129
+ The release is created as a **draft**, which is not listed publicly — so a public repo does
130
+ not start publishing your binaries as a side effect. If a release ever would be publicly
131
+ downloadable, the CLI says so.
132
+
133
+ All builds land on one reused tag, `native-sim-build`, with the asset clobbered each time,
134
+ so the release list does not fill up with build noise. Deleting that release is safe.
135
+
136
+ ### Hosting on R2 instead
137
+
138
+ Pass `--r2` when the build must live outside the repo entirely. native-sim puts it on
139
+ Cloudflare R2 and hands the runner a **presigned** URL:
140
+
141
+ ```sh
142
+ native-sim r2 # one-time: account, bucket, API token
143
+ native-sim upload ./MyApp.app --r2 # prints a presigned URL
144
+ native-sim up --app-file ./MyApp.app --r2 # upload + run in one step
145
+ ```
146
+
147
+ The bucket stays **private**; the runner gets a time-limited signed URL (2h via
148
+ `--app-file`, `--expires` on `upload`). This matters — a public bucket would leave your
149
+ build permanently downloadable by anyone who found the key.
150
+
151
+ A `.app` directory is tarred automatically. Credentials live in `~/.native-sim/r2.json`
152
+ (mode 0600) or the `R2_ACCOUNT_ID` / `R2_BUCKET` / `R2_ACCESS_KEY_ID` /
153
+ `R2_SECRET_ACCESS_KEY` environment variables, which take precedence. Create the API
154
+ token at **dash.cloudflare.com → R2 → Manage API Tokens** with Object Read & Write.
155
+ Uses the `aws` CLI against R2's S3-compatible API.
156
+
157
+ Getting an EAS build URL:
158
+
159
+ ```sh
160
+ eas build:list --platform ios --limit 5 --json --non-interactive \
161
+ | jq -r '.[0].artifacts.applicationArchiveUrl'
162
+ ```
163
+
164
+ ## Driving the simulator from a coding agent
165
+
166
+ `--agent` runs an [`agent-device`](https://agent-device.dev/) proxy next to the simulator,
167
+ reachable at `<url>/agent-device` on the **same tunnel and the same key** as the stream. A
168
+ coding agent on your machine can then tap, type, scroll and read the accessibility tree of
169
+ an app running on a GitHub runner — while you watch the same session in a browser.
170
+
171
+ The runner is a Mac with simulator access for the length of your session, which is exactly
172
+ the topology `agent-device proxy` is built for.
173
+
174
+ ### Setup
175
+
176
+ ```sh
177
+ npm install -g agent-device # once; 0.20.0 or newer
178
+ native-sim up --public --agent
179
+ ```
180
+
181
+ The runner installs **the same agent-device version you have locally** (native-sim reads
182
+ `agent-device --version` and pins it), because the client and the proxied daemon should
183
+ match. When the session comes up, the CLI prints the connect command with the URL and key
184
+ already filled in:
185
+
186
+ ```
187
+ Drive it from an agent (agent-device)
188
+
189
+ agent-device connect proxy \
190
+ --daemon-base-url https://<tunnel>.trycloudflare.com/agent-device \
191
+ --daemon-auth-token <key>
192
+ ```
193
+
194
+ ### Using it
195
+
196
+ ```sh
197
+ agent-device connect proxy --daemon-base-url <url>/agent-device --daemon-auth-token <key>
198
+
199
+ agent-device devices --platform ios # confirm the runner's simulator
200
+ agent-device open com.your.bundle.id --platform ios --device "iPhone 17 Pro"
201
+ agent-device snapshot -i # interactive elements + refs
202
+ agent-device press 'label="Explore"' --settle # a real touch injection
203
+ agent-device scroll down
204
+
205
+ agent-device close # ALWAYS close before disconnect
206
+ agent-device disconnect
207
+ ```
208
+
209
+ A real session looks like this — note the session state path is on the runner, not your Mac:
210
+
211
+ ```
212
+ Opened: com.anonymous.my-app
213
+ Session state: /Users/runner/.agent-device/sessions/proxy_adc-e8554d
214
+
215
+ Tapped label="Explore" (244, 822)
216
+ settled after 1003ms: +15 -10 (~5 unchanged)
217
+ ```
218
+
219
+ ### Gotchas worth knowing before you hit them
220
+
221
+ - **`connect proxy` succeeds even against a dead daemon.** It allocates no device lease
222
+ until `open`, so a broken session only surfaces on your first real command as
223
+ `Remote daemon is unavailable`. Check first:
224
+
225
+ ```sh
226
+ curl -H "Authorization: Bearer <key>" "<url>/agent-device/health"
227
+ # want: {"ok":true,...,"upstream":{"ok":true,...}}
228
+ # a proxy with a dead daemon answers HTTP 200 with {"ok":false,"error":"fetch failed"}
229
+ ```
230
+
231
+ That `200` matters: `curl -f` scores it a success, so a naive health check will not
232
+ catch it.
233
+
234
+ - **`close` before `disconnect`, and never delete the client state dir mid-session.** The
235
+ state dir holds the session's ownership credentials. Delete it and the runner keeps the
236
+ device claimed by an orphaned session; `open` then fails `DEVICE_IN_USE` and there is no
237
+ way back — `--force` does not cover device claims. The only fix is a new session.
238
+
239
+ - **A fresh tunnel hostname takes up to a minute to resolve.** Quick-tunnel names are
240
+ created seconds before you get them, and a resolver that answers `NXDOMAIN` may cache
241
+ that negative answer. If DNS fails immediately after a session starts, wait and retry.
242
+
243
+ - **The first snapshot needs an XCTest runner.** The workflow builds it during the session
244
+ (`agent-device prepare ios-runner`), which takes several minutes cold and seconds on a
245
+ cache hit. It runs *after* the stream URL is published, so it delays agent-readiness,
246
+ not the stream.
247
+
248
+ ### What the workflow sets, and why
249
+
250
+ Three agent-device defaults assume a developer laptop and are wrong for a single-tenant
251
+ runner that is destroyed when the job ends:
252
+
253
+ | Setting | Default | native-sim | Why |
254
+ |---|---|---|---|
255
+ | `AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS` | 5 min | `0` | The daemon reaps itself 5 minutes after the last command, and health probes do not count as activity — so it is usually gone before the agent ever connects. |
256
+ | `AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS` | 5 min | `0` | Same, for the XCTest runner. |
257
+ | `AGENT_DEVICE_LEASE_TTL_MS` | 5 min | job cap | The device lease taken by `open` expires, after which every command fails `UNAUTHORIZED`. |
258
+
259
+ Nothing leaks by disabling these: the hold loop *is* the session, and the VM dies with it.
260
+
261
+ The hold loop also **supervises the daemon** — any time `/health` is not `ok:true` it
262
+ restarts the proxy, at most once a minute. The daemon has died from an idle reap and from
263
+ a killed `xcodebuild`, so the session is built to outlive any single daemon rather than to
264
+ enumerate causes.
265
+
266
+ ## Stopping a session
267
+
268
+ ```sh
269
+ native-sim down # cancel the most recent session
270
+ native-sim down --all # cancel every native-sim run still in flight
271
+ ```
272
+
273
+ `down` reads `.git/native-sim-session.json`, which every `native-sim up` overwrites — so it
274
+ only knows the **latest** session. If you have started several, plain `down` will miss
275
+ the older ones; it warns when it detects others and `--all` catches them. You can also
276
+ cancel by id, or use the repo's Actions tab:
277
+
278
+ ```sh
279
+ gh run cancel <run-id> -R <owner>/<repo>
280
+ ```
281
+
282
+ Two things worth knowing:
283
+
284
+ - **Cancelling kills the stream instantly.** The VM is destroyed, `cloudflared` dies with
285
+ it, and the URL stops resolving immediately. There is no graceful drain, and the URL
286
+ can never be revived.
287
+ - **Sessions stop by themselves** when the hold loop reaches `--minutes`. Nothing leaks
288
+ if you do nothing. Cancelling only reclaims a runner sooner — you are capped at 5
289
+ concurrent macOS jobs — or frees your attention. On a public repo, letting them expire
290
+ costs no minutes.
291
+
292
+ ## Stream smoothness
293
+
294
+ Choppiness is usually one of three things, in this order:
295
+
296
+ 1. **Quality and resolution.** `--quality` and `--max-dimension` are the real levers.
297
+ Measured on a live session, going from serve-sim's defaults (60fps / q0.7 / 900px) to
298
+ 24fps / q0.5 / 640px cut bandwidth **2.4×**, from 1.25 to 0.52 Mbit/s — and lower
299
+ resolution is reported to feel *more responsive*, because smaller frames spend less
300
+ time on the wire. native-sim ships **900px / q0.7 / 30fps**: 640 was measurably snappier
301
+ but visibly soft on a retina display, where the preview is drawn at roughly 2× CSS
302
+ pixels. Drop to `--max-dimension 640 --quality 0.5` if you want responsiveness over
303
+ detail.
304
+
305
+ Note this does **not** fix latency. Measured round trip through a quick tunnel to a US
306
+ runner from Santiago is 320–550 ms, of which only ~45 ms is reaching Cloudflare. Smaller
307
+ frames stop *adding* to that; only a closer runner reduces it.
308
+ 2. **Frame rate — but mind which one.** serve-sim defaults MJPEG to **60 fps**, and
309
+ `serve-sim --video-fps` only sets the *h264* rate. native-sim's `--fps` now sets
310
+ `--mjpeg-fps`, which is the stream that actually runs.
311
+ 3. **Not the codec.** `/stream.avcc` (H.264) returns `200` and then emits **zero bytes**
312
+ on GitHub's macOS runners, reproducibly. The browser is pointed at `.mjpeg` regardless,
313
+ so `--codec h264` changes nothing. Default is `mjpeg`.
314
+ 4. **CPU contention.** Encoding is CPU-bound and the runner has 3 cores. A cold
315
+ `xcodebuild` running alongside the stream can saturate it hard enough that the tunnel
316
+ returns `530`. A warm build cache removes the build entirely.
317
+
318
+ **WebRTC is available but is not the default, and did not help here.** In theory its
319
+ congestion control should beat MJPEG over a long link. Tested against Cloudflare Realtime
320
+ TURN from Santiago to a US runner, it was not noticeably better — and TURN is a **paid,
321
+ per-GB relay**, so every session costs money while it runs. Reach for it only if plain
322
+ HTTP is failing you.
323
+
324
+ A quick tunnel carries no UDP, so **TURN is mandatory** for WebRTC; STUN alone cannot
325
+ traverse it.
326
+
327
+ ```sh
328
+ native-sim turn # store TURN creds as repo secrets
329
+ native-sim up --transport webrtc --public
330
+ ```
331
+
332
+ Credentials go in **repo secrets**, never workflow inputs — dispatch inputs are visible
333
+ to anyone who can read the repo, which on a public repo is everyone. Cloudflare Realtime,
334
+ Twilio, Metered, or self-hosted coturn all work. `native-sim turn --status` shows what is set.
335
+
336
+ ## Access control
337
+
338
+ `serve-sim` has no authentication, so a bare tunnel would hand simulator control
339
+ to anyone who found the URL. `native-sim` generates a per-session key and the runner
340
+ puts `.github/native-sim/gate.cjs` — a dependency-free reverse proxy — in front of it.
341
+ The key is accepted once from `?k=`, traded for an `HttpOnly` cookie, and required
342
+ on every request *and* on the control-WebSocket upgrade.
343
+
344
+ It is still a shareable bearer link. Anyone you send it to can drive the simulator.
345
+
346
+ **On a public repo, never publish the key anywhere GitHub exposes.** Commit statuses are
347
+ world-readable with no authentication, so the runner publishes only the bare tunnel URL;
348
+ the CLI appends the key it generated locally. Job logs and step summaries require auth,
349
+ but treat them the same way.
350
+
351
+ ## Cost
352
+
353
+ **Public repos are free and unlimited.** This is the whole reason native-sim is viable;
354
+ `--public` is not a detail.
355
+
356
+ Private repos bill macOS at **$0.062/min**, and against included minutes macOS carries a
357
+ **10× multiplier** — so one hour of streaming consumes 600 quota minutes, or $3.72 once
358
+ you are past your allowance. (Linux is $0.006/min for comparison.)
359
+
360
+ | Plan | Included/mo | One hour costs | macOS hours/mo |
361
+ |---|---|---|---|
362
+ | Free | 2,000 | 30% of the month | ~3.3 h |
363
+ | Pro / Team | 3,000 | 20% | 5 h |
364
+ | Enterprise | 50,000 | 1.2% | ~83 h |
365
+
366
+ A **cold build alone** is ~28 min — $1.74, or 280 quota minutes, before you see a single
367
+ frame. A warm cache reduces that to ~1 min, so on private repos the build cache is most
368
+ of the bill rather than a convenience.
369
+
370
+ `native-sim` warns when the repo is private. Free/Pro/Team also cap concurrent macOS jobs
371
+ at 5.
372
+
373
+ Rates verified against GitHub's billing docs; they change, so re-check before relying on
374
+ them.
375
+
376
+ ## Limits worth knowing
377
+
378
+ - **Ephemeral.** The runner VM is destroyed when the job ends. There is no persistent simulator.
379
+ - **6 hours, hard.** GitHub kills hosted jobs at 360 minutes.
380
+ - **MJPEG over a tunnel**, not WebRTC. WebRTC needs a UDP path a quick tunnel can't provide without TURN; pass `--turn-url` to `serve-sim` in the workflow if you have one.
381
+ - **A small box.** Standard runners are ~3 vCPU / 7 GB running Xcode, a simulator, and a video encoder. During a *cold* build, `xcodebuild` and the video capture compete for those 3 cores, which shows up as `control socket connect timeout` and "connecting" churn in the preview. A warm cache avoids the build entirely and the stream stays smooth.
382
+ - **GitHub's Actions terms** cover building, testing and publishing *the software in that repo*. Occasional PR-preview sessions fit; a 24/7 public simulator host does not, and GitHub reserves the right to throttle it.
383
+
384
+ If you want this often, a Mac mini as a self-hosted runner is cheaper and faster
385
+ than fighting the constraints.
386
+
387
+ ## Requirements
388
+
389
+ - `gh` CLI, authenticated (`gh auth login`)
390
+ - Node 20+
391
+ - An Expo project (`expo` in `package.json`)
392
+
393
+ ## License
394
+
395
+ MIT
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ main(process.argv.slice(2)).catch((err) => {
5
+ console.error(`\x1b[31merror\x1b[0m ${err?.message ?? err}`);
6
+ if (process.env.NATIVE_SIM_DEBUG) console.error(err);
7
+ process.exit(1);
8
+ });
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "native-sim",
3
+ "version": "0.1.0",
4
+ "description": "Stream a live iOS Simulator of your React Native or Expo app from a GitHub-hosted macOS runner to your browser — and let a coding agent drive it.",
5
+ "keywords": [
6
+ "expo",
7
+ "react-native",
8
+ "ios-simulator",
9
+ "github-actions",
10
+ "simulator",
11
+ "agent-device",
12
+ "serve-sim",
13
+ "preview"
14
+ ],
15
+ "homepage": "https://reactnativefeel.com/sim",
16
+ "author": "Rodrigo Figueroa (https://x.com/bidah)",
17
+ "license": "MIT",
18
+ "type": "module",
19
+ "bin": {
20
+ "native-sim": "bin/native-sim.js"
21
+ },
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "src",
28
+ "templates",
29
+ "README.md"
30
+ ]
31
+ }
package/src/cli.js ADDED
@@ -0,0 +1,151 @@
1
+ import { init } from './commands/init.js';
2
+ import { up } from './commands/up.js';
3
+ import { status } from './commands/status.js';
4
+ import { down } from './commands/down.js';
5
+ import { doctor } from './commands/doctor.js';
6
+ import { upload } from './commands/upload.js';
7
+ import { r2Setup } from './commands/r2.js';
8
+ import { turn } from './commands/turn.js';
9
+ import { readFileSync } from 'node:fs';
10
+ import { bold, dim, cyan } from './lib/ui.js';
11
+
12
+ const HELP = `
13
+ ${bold('native-sim')} — stream an iOS Simulator of your Expo app from a GitHub Actions runner
14
+
15
+ ${bold('USAGE')}
16
+ native-sim <command> [options]
17
+
18
+ ${bold('COMMANDS')}
19
+ up Push, build on a macOS runner, and open the live simulator ${dim('(default)')}
20
+ init Write .github/workflows/native-sim.yml and the auth gate
21
+ status Show the current session and its stream URL
22
+ down Cancel the run and tear the stream down ${dim('(--all for every session)')}
23
+ doctor Check prerequisites
24
+ upload Upload a simulator build to this repo's release ${dim('(--r2 for R2)')}
25
+ r2 Configure R2 credentials, for --r2 ${dim('(--status to inspect)')}
26
+ turn Store TURN credentials as repo secrets, for --transport webrtc
27
+
28
+ ${bold('OPTIONS')} ${dim('(native-sim up)')}
29
+ --minutes <n> How long to hold the stream open ${dim('default 30, max 350')}
30
+ --device <name> Simulator device ${dim('default "iPhone 17 Pro"')}
31
+ --app-file <p> Upload a local .app/.tar.gz and run it ${dim('(hosted on this repo)')}
32
+ --app-release <a> Run an asset already on this repo's native-sim-build release
33
+ --r2 Host --app-file on Cloudflare R2 instead ${dim('(needs: native-sim r2)')}
34
+ --app <url> Install an already-built simulator .app from a URL
35
+ ${dim('no source is pushed; the repo holds only the workflow')}
36
+ --mode <m> build | app | go ${dim('default build')}
37
+ --scheme <name> Xcode scheme ${dim('default: generated workspace name')}
38
+ --runner <label> Runner label ${dim('default macos-26')}
39
+ --repo <name> Repo name when creating one
40
+ --transport <t> http | webrtc ${dim('default http; webrtc is paid + no better')}
41
+ --codec <c> mjpeg | h264 ${dim('default mjpeg; h264 is dead on GH runners')}
42
+ --max-dimension Cap captured px; 0 = native ${dim('default 900; lower = snappier, softer')}
43
+ --fps <n> MJPEG frame rate ${dim('default 30; serve-sim default is 60')}
44
+ --quality <n> MJPEG quality 0.05-1 ${dim('default 0.7')}
45
+ --agent Also expose an agent-device proxy so a coding agent can
46
+ drive the simulator ${dim('(prints the connect command)')}
47
+ --export Also download the built .app archive ${dim('(--out <dir>)')}
48
+ --no-cache Force a full native rebuild ${dim('(cache is on by default)')}
49
+ --public Create the repo public ${dim('(unlimited free macOS minutes)')}
50
+ --message <msg> Commit message
51
+ --no-open Do not open the browser
52
+
53
+ ${bold('EXAMPLES')}
54
+ ${cyan('native-sim up --public --minutes 45')}
55
+ ${cyan('native-sim up --public --agent')} ${dim('# stream + agent-device control')}
56
+ ${cyan('native-sim up --mode go --device "iPhone 17"')}
57
+ ${cyan('native-sim up --app https://expo.dev/artifacts/eas/xxxx.tar.gz --public')}
58
+ ${cyan('native-sim up --app-file ./MyApp.app')} ${dim('# hosted on your own repo')}
59
+ ${cyan('native-sim upload ./build/MyApp.app.tar.gz')}
60
+ ${cyan('native-sim down')}
61
+ `;
62
+
63
+ const NEEDS_VALUE = new Set(['minutes', 'device', 'mode', 'scheme', 'runner', 'repo', 'message', 'app', 'app-file', 'app-release', 'expires', 'out', 'codec', 'max-dimension', 'fps', 'quality', 'transport']);
64
+
65
+ export function parseArgs(argv) {
66
+ const flags = {};
67
+ const positional = [];
68
+
69
+ // Single-dash aliases. Without this `-h` is pushed as a positional and comes
70
+ // back as `Unknown command "-h"`, which is a poor first impression.
71
+ const SHORT = { h: 'help', v: 'version' };
72
+
73
+ for (let i = 0; i < argv.length; i++) {
74
+ const arg = argv[i];
75
+ if (!arg.startsWith('--')) {
76
+ if (/^-[a-z]+$/.test(arg)) {
77
+ for (const ch of arg.slice(1)) {
78
+ if (SHORT[ch]) flags[SHORT[ch]] = true;
79
+ }
80
+ continue;
81
+ }
82
+ positional.push(arg);
83
+ continue;
84
+ }
85
+ let key = arg.slice(2);
86
+ let value;
87
+ const eq = key.indexOf('=');
88
+ if (eq !== -1) {
89
+ value = key.slice(eq + 1);
90
+ key = key.slice(0, eq);
91
+ }
92
+ if (key.startsWith('no-')) {
93
+ flags[key.slice(3)] = false;
94
+ continue;
95
+ }
96
+ if (NEEDS_VALUE.has(key)) {
97
+ value ??= argv[++i];
98
+ if (value === undefined) throw new Error(`--${key} needs a value`);
99
+ flags[key] = key === 'minutes' ? Number(value) : value;
100
+ continue;
101
+ }
102
+ flags[key] = value ?? true;
103
+ }
104
+
105
+ flags._positional = positional.slice(1);
106
+ return { command: positional[0] ?? 'up', flags };
107
+ }
108
+
109
+ export async function main(argv) {
110
+ const { command, flags } = parseArgs(argv);
111
+
112
+ if (flags.help || command === 'help') {
113
+ console.log(HELP);
114
+ return;
115
+ }
116
+
117
+ if (flags.version) {
118
+ const pkg = JSON.parse(
119
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
120
+ );
121
+ console.log(pkg.version);
122
+ return;
123
+ }
124
+
125
+ if (flags.minutes !== undefined && (!Number.isFinite(flags.minutes) || flags.minutes < 1 || flags.minutes > 350)) {
126
+ throw new Error('--minutes must be between 1 and 350 (GitHub kills hosted jobs at 6 hours)');
127
+ }
128
+ if (flags.transport && !['http', 'webrtc'].includes(flags.transport)) {
129
+ throw new Error(`--transport must be "http" or "webrtc", got "${flags.transport}"`);
130
+ }
131
+ if (flags.codec && !['h264', 'mjpeg', 'auto'].includes(flags.codec)) {
132
+ throw new Error(`--codec must be "h264", "mjpeg" or "auto", got "${flags.codec}"`);
133
+ }
134
+ if (flags.mode && !['build', 'app', 'go'].includes(flags.mode)) {
135
+ throw new Error(`--mode must be "build", "app" or "go", got "${flags.mode}"`);
136
+ }
137
+ if (typeof flags.app === 'string' && !/^https?:\/\//.test(flags.app)) {
138
+ throw new Error(
139
+ `--app needs a URL the runner can download, got "${flags.app}".\n` +
140
+ `A local path will not work — the runner cannot reach your machine.\n` +
141
+ `Use an EAS build URL, a release asset, or any reachable https:// link.`,
142
+ );
143
+ }
144
+
145
+ const cwd = process.cwd();
146
+ const commands = { up, init, status, down, doctor, upload, r2: r2Setup, turn };
147
+ const handler = commands[command];
148
+ if (!handler) throw new Error(`Unknown command "${command}". Try: native-sim help`);
149
+
150
+ await handler(cwd, flags);
151
+ }
@@ -0,0 +1,54 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { sh, has } from '../lib/proc.js';
4
+ import { assertExpoProject } from '../lib/project.js';
5
+ import { WORKFLOW_PATH, GATE_PATH } from './init.js';
6
+ import { green, red, yellow, dim, bold } from '../lib/ui.js';
7
+
8
+ const PASS = green('✓');
9
+ const FAIL = red('✗');
10
+ const WARN = yellow('!');
11
+
12
+ export async function doctor(cwd) {
13
+ const checks = [];
14
+ const add = (icon, label, detail) => checks.push(` ${icon} ${label}${detail ? ` ${dim(detail)}` : ''}`);
15
+
16
+ try {
17
+ const pkg = assertExpoProject(cwd);
18
+ add(PASS, 'Expo project', pkg.name);
19
+ } catch (err) {
20
+ add(FAIL, 'Expo project', err.message.split('\n')[0]);
21
+ }
22
+
23
+ add(has('git') ? PASS : FAIL, 'git');
24
+
25
+ if (!has('gh')) {
26
+ add(FAIL, 'gh CLI', 'brew install gh');
27
+ } else if (!sh('gh', ['auth', 'status']).ok) {
28
+ add(FAIL, 'gh CLI authenticated', 'gh auth login');
29
+ } else {
30
+ const user = sh('gh', ['api', 'user', '-q', '.login']);
31
+ add(PASS, 'gh CLI authenticated', user.out);
32
+ }
33
+
34
+ const [major] = process.versions.node.split('.').map(Number);
35
+ add(major >= 20 ? PASS : FAIL, 'Node >= 20', `v${process.versions.node}`);
36
+
37
+ add(existsSync(join(cwd, WORKFLOW_PATH)) ? PASS : WARN, WORKFLOW_PATH,
38
+ existsSync(join(cwd, WORKFLOW_PATH)) ? '' : 'run native-sim init');
39
+ add(existsSync(join(cwd, GATE_PATH)) ? PASS : WARN, GATE_PATH,
40
+ existsSync(join(cwd, GATE_PATH)) ? '' : 'run native-sim init');
41
+
42
+ const repo = sh('gh', ['repo', 'view', '--json', 'nameWithOwner,visibility', '-q',
43
+ '.nameWithOwner + " (" + .visibility + ")"'], { cwd });
44
+ if (repo.ok) {
45
+ const isPublic = /PUBLIC/.test(repo.out);
46
+ add(isPublic ? PASS : WARN, 'GitHub remote', isPublic ? repo.out : `${repo.out} — macOS minutes bill at 10×`);
47
+ } else {
48
+ add(WARN, 'GitHub remote', 'none yet — native-sim up will create one');
49
+ }
50
+
51
+ console.log(`\n${bold('native-sim doctor')}\n`);
52
+ console.log(checks.join('\n'));
53
+ console.log('');
54
+ }