rn-iso 0.1.0 → 0.2.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/LICENSE +21 -0
- package/README.md +41 -36
- package/bin/cli.js +0 -6
- package/package.json +28 -2
- package/skill/SKILL.md +41 -43
- package/src/commands/android.js +120 -14
- package/src/commands/ios.js +95 -33
- package/src/commands/release.js +19 -25
- package/src/commands/reserve.js +141 -144
- package/src/commands/status.js +1 -15
- package/src/commands/stop.js +62 -30
- package/src/commands/unreserve.js +23 -43
- package/src/config.js +14 -91
- package/src/labels.js +25 -0
- package/src/project.js +25 -7
- package/src/sim/android.js +31 -18
- package/src/sim/ios.js +7 -1
- package/.claude/settings.local.json +0 -7
- package/CLAUDE.md +0 -178
- package/docs/plans/2026-04-25-rn-iso-implementation.md +0 -2653
- package/docs/specs/2026-04-25-rn-iso-design.md +0 -282
- package/src/commands/logs.js +0 -28
- package/src/commands/prune.js +0 -57
- package/src/commands/shutdown.js +0 -41
- package/test/config.test.js +0 -208
- package/test/exec.test.js +0 -26
- package/test/fixtures/sample-bare-project/android/app/build.gradle +0 -6
- package/test/fixtures/sample-bare-project/ios/SampleApp.xcodeproj/project.pbxproj +0 -10
- package/test/fixtures/sample-bare-project/package.json +0 -4
- package/test/fixtures/sample-expo-project/app.json +0 -6
- package/test/fixtures/sample-expo-project/package.json +0 -4
- package/test/fixtures/sample-expo-project/src/.keep +0 -0
- package/test/metro.test.js +0 -34
- package/test/ports.test.js +0 -76
- package/test/project.test.js +0 -109
- package/test/runner.test.js +0 -209
- package/test/sim-android.test.js +0 -140
- package/test/sim-ios.test.js +0 -168
|
@@ -1,282 +0,0 @@
|
|
|
1
|
-
# rn-iso — design
|
|
2
|
-
|
|
3
|
-
Date: 2026-04-25
|
|
4
|
-
Status: draft
|
|
5
|
-
|
|
6
|
-
## Purpose
|
|
7
|
-
|
|
8
|
-
A CLI that lets you run multiple React Native / Expo projects (or worktrees of the same project) concurrently on one machine, each with its own Metro server and its own simulator/emulator, without manually juggling ports or device targets.
|
|
9
|
-
|
|
10
|
-
Primary motivator: AI coding agents working in parallel across multiple worktrees. Each agent — and any UI-driving skill it uses — should be able to "just work" against the right device without the user wiring it up by hand.
|
|
11
|
-
|
|
12
|
-
Secondary motivator: a human jumping between several projects locally, without colliding ports and without mental overhead about which sim is which.
|
|
13
|
-
|
|
14
|
-
## Non-goals (v1)
|
|
15
|
-
|
|
16
|
-
- **No shared-device mode and no mutex/lock.** With dedicated sims per project, contention disappears. If a user is on tight hardware and wants one shared sim, they can use `react-native-worktree` instead.
|
|
17
|
-
- **No build-cache shenanigans.** Trust `expo run:ios` / `react-native run-ios` and the underlying toolchain.
|
|
18
|
-
- **No automatic shutdown of simulators.** Manual via `prune --shutdown` or `shutdown`. Users frequently bounce between agents and don't want their sim killed out from under them.
|
|
19
|
-
- **No Expo Go support.** Custom dev clients only — Expo Go can't have its Metro target rewritten cleanly.
|
|
20
|
-
- **No concurrency limits / build-slot semaphore.** Add later if it proves to be a problem.
|
|
21
|
-
|
|
22
|
-
## User scenarios
|
|
23
|
-
|
|
24
|
-
### Scenario A — multi-agent, multiple worktrees
|
|
25
|
-
|
|
26
|
-
Janic spawns three Claude Code sessions in three worktrees of the same app. Each runs `rn-iso ios` once. Each gets:
|
|
27
|
-
- A unique Metro port (8082, 8083, 8084).
|
|
28
|
-
- A dedicated booted iOS simulator (different UDIDs).
|
|
29
|
-
- An installed copy of the app pointing to that worktree's port.
|
|
30
|
-
|
|
31
|
-
Each agent calls `rn-iso device` to learn its UDID and uses it for `agent-device` calls. No locking required; no agent disturbs another.
|
|
32
|
-
|
|
33
|
-
### Scenario B — solo multi-project switching
|
|
34
|
-
|
|
35
|
-
Janic works on app A in the morning and app B in the afternoon. Each project's first `rn-iso ios` invocation assigned it a sim. Going back to app A's directory and running `rn-iso ios` boots its sim if shut down and reuses everything else.
|
|
36
|
-
|
|
37
|
-
### Scenario C — agent working alongside human
|
|
38
|
-
|
|
39
|
-
Agent owns sim X on port 8083. Human runs the same app from a different worktree on sim Y, port 8084. They don't interact. No protocol required.
|
|
40
|
-
|
|
41
|
-
## Architecture
|
|
42
|
-
|
|
43
|
-
### Global config
|
|
44
|
-
|
|
45
|
-
Stored at `~/.rn-iso/config.json`. Keyed by absolute project path (resolved via `realpath` to handle symlinks consistently).
|
|
46
|
-
|
|
47
|
-
```json
|
|
48
|
-
{
|
|
49
|
-
"version": 1,
|
|
50
|
-
"projects": {
|
|
51
|
-
"/Users/janic/Developer/myapp": {
|
|
52
|
-
"bundleId": "com.myapp",
|
|
53
|
-
"androidPackage": "com.myapp",
|
|
54
|
-
"isExpo": true,
|
|
55
|
-
"metroPort": 8082,
|
|
56
|
-
"metroPid": 12345,
|
|
57
|
-
"platforms": {
|
|
58
|
-
"ios": {
|
|
59
|
-
"deviceUdid": "A1B2C3D4-..."
|
|
60
|
-
},
|
|
61
|
-
"android": {
|
|
62
|
-
"avdName": "Pixel_6_API_34",
|
|
63
|
-
"consolePort": 5554
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
},
|
|
67
|
-
"/Users/janic/Developer/myapp-feat-auth": {
|
|
68
|
-
"...": "..."
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
Notes:
|
|
75
|
-
- Single Metro per project, shared across iOS and Android (same as `react-native run-*` defaults).
|
|
76
|
-
- `metroPort` and `metroPid` live at the project level — one Metro serves both platforms.
|
|
77
|
-
- `metroPid` is recorded when Metro is started detached, used by `rn-iso stop` and `rn-iso logs`.
|
|
78
|
-
- The project path is the key — git worktrees produce different absolute paths, so they're naturally separate entries.
|
|
79
|
-
- Schema is versioned to allow forward migrations.
|
|
80
|
-
|
|
81
|
-
### Project detection
|
|
82
|
-
|
|
83
|
-
`rn-iso` resolves the "current project" by walking up from CWD until it finds a directory containing `package.json`. That directory is the canonical project root used as the config key.
|
|
84
|
-
|
|
85
|
-
Inside that root, detection determines:
|
|
86
|
-
- **Bare vs Expo:** presence of `expo` in `package.json` dependencies AND presence of `app.json` / `app.config.{js,ts}`. If both, treat as Expo.
|
|
87
|
-
- **Bundle ID / package name:** lifted from the existing `react-native-worktree` logic — checks `app.json` (`expo.ios.bundleIdentifier`, `expo.android.package`) and `app.config.{js,ts}` via regex.
|
|
88
|
-
|
|
89
|
-
### Port allocation
|
|
90
|
-
|
|
91
|
-
Same logic as `react-native-worktree`:
|
|
92
|
-
- On first assignment for a project, scan all ports in config across all projects, plus probe `localhost:<port>/status` to detect dead Metros.
|
|
93
|
-
- If a dead Metro port is found, reclaim it and remove the dead entry.
|
|
94
|
-
- Else assign `max(allPorts, 8081) + 1`.
|
|
95
|
-
|
|
96
|
-
Both platforms in the same project share one Metro port.
|
|
97
|
-
|
|
98
|
-
### Device assignment
|
|
99
|
-
|
|
100
|
-
Sticky and explicit. First `rn-iso ios` for a project picks a sim and writes it to config. Subsequent invocations reuse it.
|
|
101
|
-
|
|
102
|
-
**Selection algorithm (iOS):**
|
|
103
|
-
1. List booted sims via `xcrun simctl list devices booted -j`.
|
|
104
|
-
2. Compute "claimed" UDIDs from config (across all projects).
|
|
105
|
-
3. **Prefer reuse:** if config has a UDID for this project and it's bootable (exists in `simctl list devices`), use it. Boot it if not booted.
|
|
106
|
-
4. **Else allocate from booted-and-unclaimed:** pick the first booted sim that's not claimed by any project.
|
|
107
|
-
5. **Else prompt to boot a new one:**
|
|
108
|
-
- Interactive: `xcrun simctl list devicetypes` → arrow-key picker; default to the most recently used iPhone runtime.
|
|
109
|
-
- Non-interactive (`--auto` / `--device-type "iPhone 15 Pro"`): boot a fresh sim of that type, no prompt.
|
|
110
|
-
6. Boot it via `xcrun simctl boot <UDID>` and `open -a Simulator`.
|
|
111
|
-
|
|
112
|
-
**Selection algorithm (Android):**
|
|
113
|
-
1. List existing AVDs via `emulator -list-avds`.
|
|
114
|
-
2. List currently-running emulator console ports via `adb devices` (entries like `emulator-5554`).
|
|
115
|
-
3. Compute claimed AVDs and console ports from config.
|
|
116
|
-
4. Prefer reuse: if config has an AVD for this project, start it on its assigned console port if not running.
|
|
117
|
-
5. Else pick an unclaimed AVD; assign next free even console port starting at 5554.
|
|
118
|
-
6. Else prompt to create a new AVD (or fail with a helpful message — AVD creation is gnarly enough that v1 may just instruct the user to create one in Android Studio).
|
|
119
|
-
|
|
120
|
-
When booting Android: `emulator -avd <name> -port <consolePort>` (detached).
|
|
121
|
-
|
|
122
|
-
### App install
|
|
123
|
-
|
|
124
|
-
iOS:
|
|
125
|
-
- `expo run:ios --device <UDID> --port <PORT>` (Expo) or `react-native run-ios --simulator <name> --port <PORT>` (bare; note: bare RN takes a name, not UDID — translate via `simctl list`).
|
|
126
|
-
- These build, install, and launch.
|
|
127
|
-
- On reruns of `rn-iso ios` against the same project + sim, this is fast because the build is incremental.
|
|
128
|
-
|
|
129
|
-
Android:
|
|
130
|
-
- `expo run:android --device <serial> --port <PORT>` (Expo) or `react-native run-android --deviceId <serial>` (bare).
|
|
131
|
-
- After install, set up `adb -s <serial> reverse tcp:<PORT> tcp:<PORT>` so the emulator can reach Metro on the host.
|
|
132
|
-
|
|
133
|
-
### Metro management
|
|
134
|
-
|
|
135
|
-
`rn-iso ios` / `rn-iso android` ensure Metro is running on the project's assigned port:
|
|
136
|
-
- If a process is already responding to `/status` on that port, leave it alone.
|
|
137
|
-
- Else spawn detached: `npx expo start --port <PORT>` or `npx react-native start --port <PORT>`. Record PID in config. Pipe stdout/stderr to `~/.rn-iso/logs/<project-hash>-metro.log`.
|
|
138
|
-
|
|
139
|
-
`rn-iso start` is the standalone form: ensure Metro, do nothing else.
|
|
140
|
-
`rn-iso logs` tails the log file.
|
|
141
|
-
`rn-iso stop` kills Metro by PID.
|
|
142
|
-
`rn-iso status` shows per-project state.
|
|
143
|
-
|
|
144
|
-
### Cleanup
|
|
145
|
-
|
|
146
|
-
- `rn-iso release [--platform <p>]` — clear device assignment for the current project. Does not shut down the sim.
|
|
147
|
-
- `rn-iso shutdown [--platform <p>]` — `simctl shutdown <UDID>` / kill emulator process for the current project. Releases assignment.
|
|
148
|
-
- `rn-iso prune [--shutdown]` — scan config:
|
|
149
|
-
- Drop projects whose path no longer exists.
|
|
150
|
-
- Drop platform assignments whose UDID/AVD no longer exists.
|
|
151
|
-
- With `--shutdown`: also shut down any sims/emulators referenced only by dropped entries.
|
|
152
|
-
|
|
153
|
-
## Command surface
|
|
154
|
-
|
|
155
|
-
```
|
|
156
|
-
rn-iso ios [--device-type <name>] [--detach] [--auto]
|
|
157
|
-
rn-iso android [--device-type <name>] [--detach] [--auto]
|
|
158
|
-
rn-iso start [--detach] # metro only
|
|
159
|
-
rn-iso device [--platform ios|android] # print UDID/serial; exit 0 if assigned, 1 if not
|
|
160
|
-
rn-iso status # all projects, current project highlighted
|
|
161
|
-
rn-iso release [--platform ios|android] # unbind device(s) for current project
|
|
162
|
-
rn-iso shutdown [--platform ios|android] # release + shut down sim(s)
|
|
163
|
-
rn-iso prune [--shutdown] # GC dead entries machine-wide
|
|
164
|
-
rn-iso logs [--platform ios|android] # tail metro logs
|
|
165
|
-
rn-iso stop # kill metro for current project
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
The "current project" is always the package.json root walking up from CWD. No flags to override.
|
|
169
|
-
|
|
170
|
-
## Platform specifics
|
|
171
|
-
|
|
172
|
-
### iOS
|
|
173
|
-
|
|
174
|
-
- Boot: `xcrun simctl boot <UDID>` (idempotent — errors if already booted; ignore "Booted" error).
|
|
175
|
-
- Install: `expo run:ios --device <UDID>` or equivalent.
|
|
176
|
-
- Launch: handled by run-ios. To relaunch later: `xcrun simctl terminate <UDID> <bundleId>` + `xcrun simctl launch <UDID> <bundleId>`.
|
|
177
|
-
- Port-on-existing-app trick (carried over from `react-native-worktree`):
|
|
178
|
-
```
|
|
179
|
-
xcrun simctl spawn <UDID> defaults write <bundleId> RCT_jsLocation "localhost:<port>"
|
|
180
|
-
```
|
|
181
|
-
Used when the user wants to repoint without rebuilding (rare in dedicated-sim mode but useful for "I changed Metro port" or "I moved this worktree's port to reclaim").
|
|
182
|
-
|
|
183
|
-
### Android (more involved than iOS)
|
|
184
|
-
|
|
185
|
-
- Each emulator instance occupies a console port (`-port <consolePort>`); ADB serial is `emulator-<consolePort>`.
|
|
186
|
-
- Reverse port mapping is per-device: `adb -s emulator-5554 reverse tcp:<MetroPort> tcp:<MetroPort>`.
|
|
187
|
-
- Boot is slow (10-30s); we should poll `adb -s <serial> shell getprop sys.boot_completed` until "1" before trying to install.
|
|
188
|
-
- Multiple instances of the same AVD are technically supported (`-port` differs), but each instance keeps a private system image overlay; clean disk usage matters. For v1, prefer one AVD per running instance.
|
|
189
|
-
- `debug_http_host` SharedPref trick (from `react-native-worktree`) is the equivalent of iOS `RCT_jsLocation` for repointing without rebuilding.
|
|
190
|
-
|
|
191
|
-
### Bare vs Expo dispatch
|
|
192
|
-
|
|
193
|
-
| Action | Expo | Bare |
|
|
194
|
-
|---|---|---|
|
|
195
|
-
| Run iOS | `npx expo run:ios --device <UDID> --port <P>` | `npx react-native run-ios --simulator "<name>" --port <P>` |
|
|
196
|
-
| Run Android | `npx expo run:android --device <serial> --port <P>` | `npx react-native run-android --deviceId <serial>` (bare RN takes port via `RCT_METRO_PORT` env) |
|
|
197
|
-
| Start Metro | `npx expo start --port <P>` | `npx react-native start --port <P>` |
|
|
198
|
-
|
|
199
|
-
Bare RN's `run-ios` takes a device *name*, not a UDID — we'll resolve UDID → name via `simctl list devices -j`. If multiple sims share a name (common), error out with "ambiguous; please rename one in the Simulator app."
|
|
200
|
-
|
|
201
|
-
## Agent integration
|
|
202
|
-
|
|
203
|
-
A skill (provisional name `rn-iso`) shipped with the CLI that teaches agents how to use it. Skill content covers:
|
|
204
|
-
- Run `rn-iso ios` (or android) from the project root before any device interaction.
|
|
205
|
-
- Use `rn-iso device --platform ios` to get the UDID, pass to `agent-device`'s `--device <UDID>` form (or whatever the platform-specific flag is).
|
|
206
|
-
- Pass `--auto --device-type "iPhone 15 Pro"` (or similar) for non-interactive runs.
|
|
207
|
-
- Always reuse — never call `release` or `shutdown` unless the user asks.
|
|
208
|
-
|
|
209
|
-
The skill installs the same way as `react-native-worktree`'s skill (`curl ... -o ~/.claude/skills/rn-iso/SKILL.md`).
|
|
210
|
-
|
|
211
|
-
`rn-iso device` is the single point of integration. Output format:
|
|
212
|
-
- Success: `<UDID>` (or `<emulator-serial>`) on stdout, exit 0.
|
|
213
|
-
- Not assigned: empty stdout, error message on stderr, exit 1.
|
|
214
|
-
- `--platform <p>` filters; default = ios.
|
|
215
|
-
- `--json` form returns `{ "platform": "ios", "udid": "...", "metroPort": 8083 }` for tooling.
|
|
216
|
-
|
|
217
|
-
## Failure modes & error handling
|
|
218
|
-
|
|
219
|
-
| Situation | Behavior |
|
|
220
|
-
|---|---|
|
|
221
|
-
| No `package.json` found walking up from CWD | Error: "Not in a React Native project (no package.json)" |
|
|
222
|
-
| Bundle ID undetectable | Error: "Could not detect bundle ID; provide via `--bundle-id <id>`" (later — v1 requires app.json) |
|
|
223
|
-
| Assigned sim no longer exists | Warn, drop assignment, run normal allocation |
|
|
224
|
-
| Assigned sim exists but won't boot | Error with the simctl message; don't auto-fall-through |
|
|
225
|
-
| Metro port collides with non-rn-iso process | Detect via probe; if `/status` doesn't return `packager-status:running`, error: "port X busy by non-Metro process" |
|
|
226
|
-
| Two instances of `rn-iso ios` for the same project run concurrently | File-based lock on the project's config entry during mutation. Race-window only during allocation. |
|
|
227
|
-
| Expo run hangs / fails | Surface stderr; don't silently retry |
|
|
228
|
-
|
|
229
|
-
## Testing strategy
|
|
230
|
-
|
|
231
|
-
- **Unit tests** for: config schema parsing/migration, port allocation, project root detection, bundle ID detection, device-pool selection algorithm (mocked simctl/adb output).
|
|
232
|
-
- **Integration tests** are hard for sim/emulator interactions and out of scope for v1; rely on manual verification on the developer's machine.
|
|
233
|
-
- Use `node --test` (same as `react-native-worktree` — no test framework deps).
|
|
234
|
-
|
|
235
|
-
## Implementation notes
|
|
236
|
-
|
|
237
|
-
- Language: JavaScript (ESM), no TS toolchain. Match `react-native-worktree`'s style for legibility / cross-pollination.
|
|
238
|
-
- Dependencies: `commander` (CLI), `chalk` (color), `prompts` or built-in readline (interactive picker). Prefer minimal deps.
|
|
239
|
-
- File layout:
|
|
240
|
-
```
|
|
241
|
-
bin/cli.js
|
|
242
|
-
src/
|
|
243
|
-
commands/
|
|
244
|
-
ios.js
|
|
245
|
-
android.js
|
|
246
|
-
start.js
|
|
247
|
-
device.js
|
|
248
|
-
status.js
|
|
249
|
-
release.js
|
|
250
|
-
shutdown.js
|
|
251
|
-
prune.js
|
|
252
|
-
logs.js
|
|
253
|
-
stop.js
|
|
254
|
-
config.js # global config CRUD, project root detection, bundle ID detection
|
|
255
|
-
ports.js # port allocation + Metro probing
|
|
256
|
-
sim/
|
|
257
|
-
ios.js # simctl wrappers + iOS device pool
|
|
258
|
-
android.js # adb/emulator wrappers + Android device pool
|
|
259
|
-
metro.js # detached spawn, log piping, PID lifecycle
|
|
260
|
-
runner.js # bare vs Expo dispatch
|
|
261
|
-
test/
|
|
262
|
-
*.test.js
|
|
263
|
-
skill/
|
|
264
|
-
SKILL.md
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
## Open questions
|
|
268
|
-
|
|
269
|
-
1. **Skill name.** `rn-iso` is fine for the CLI. The skill name visible to agents could be the same, or something more discoverable like `react-native-isolated-dev`. I'll default to `rn-iso` and we can rename later.
|
|
270
|
-
2. **Coexistence with `react-native-worktree`.** A user might have both installed. They don't conflict (different config dirs), but the skill descriptions might both trigger and confuse agents. The skill description should be specific enough that agents only invoke `rn-iso` when the user has set it up.
|
|
271
|
-
3. **Multi-app projects (one repo, multiple Expo apps via `expo prebuild --variant`).** Out of scope for v1. Treat each project path as a single app.
|
|
272
|
-
4. **Windows / Linux support.** macOS-first. Android-on-Linux is plausible later. iOS is mac-only by definition.
|
|
273
|
-
5. **Should `device --json` include the Metro port?** Yes for v1 — agents may want to verify Metro is up before kicking off a UI test.
|
|
274
|
-
6. **AVD creation in `rn-iso android` first-run.** Defer to user; instruct them to use Android Studio. Revisit if it's a common pain point.
|
|
275
|
-
|
|
276
|
-
## Future / later
|
|
277
|
-
|
|
278
|
-
- Build-slot semaphore (limit concurrent native builds).
|
|
279
|
-
- Auto-shutdown sim after N hours of inactivity (opt-in).
|
|
280
|
-
- Hot-rebind without rebuild via `RCT_jsLocation` / `debug_http_host` (the `react-native-worktree` trick) — exposed as `rn-iso rebind` for power users.
|
|
281
|
-
- TUI dashboard.
|
|
282
|
-
- Replace `commander` with a smaller arg parser if package size becomes a concern.
|
package/src/commands/logs.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
// src/commands/logs.js
|
|
2
|
-
import chalk from 'chalk';
|
|
3
|
-
import { findProjectRoot } from '../project.js';
|
|
4
|
-
import { logFileExists } from '../metro.js';
|
|
5
|
-
import { getExecutor } from '../exec.js';
|
|
6
|
-
|
|
7
|
-
export default function logsCommand(program) {
|
|
8
|
-
program
|
|
9
|
-
.command('logs')
|
|
10
|
-
.description('Tail the Metro log file for the current project')
|
|
11
|
-
.action(() => {
|
|
12
|
-
const root = findProjectRoot(process.cwd());
|
|
13
|
-
if (!root) {
|
|
14
|
-
console.error(chalk.red('Not in a React Native project.'));
|
|
15
|
-
process.exit(1);
|
|
16
|
-
}
|
|
17
|
-
const path = logFileExists(root);
|
|
18
|
-
if (!path) {
|
|
19
|
-
console.error(chalk.red('No Metro log file found. Have you run `rn-iso start` or `rn-iso ios/android`?'));
|
|
20
|
-
process.exit(1);
|
|
21
|
-
}
|
|
22
|
-
console.log(chalk.dim(`Tailing ${path}\n`));
|
|
23
|
-
const exec = getExecutor();
|
|
24
|
-
const child = exec.spawn('tail', ['-f', path], { stdio: 'inherit' });
|
|
25
|
-
// Forward SIGINT cleanly
|
|
26
|
-
process.on('SIGINT', () => child.kill('SIGINT'));
|
|
27
|
-
});
|
|
28
|
-
}
|
package/src/commands/prune.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
// src/commands/prune.js
|
|
2
|
-
import { existsSync } from 'fs';
|
|
3
|
-
import chalk from 'chalk';
|
|
4
|
-
import { loadConfig, removeProject, clearDevice } from '../config.js';
|
|
5
|
-
import { listAllIosSims, shutdownIosSim } from '../sim/ios.js';
|
|
6
|
-
import { listAvds, shutdownAndroidEmulator } from '../sim/android.js';
|
|
7
|
-
|
|
8
|
-
export default function pruneCommand(program) {
|
|
9
|
-
program
|
|
10
|
-
.command('prune')
|
|
11
|
-
.description('Garbage-collect dead project entries and missing device assignments')
|
|
12
|
-
.option('--shutdown', 'Also shut down sims/emulators referenced only by dropped entries')
|
|
13
|
-
.action((opts) => {
|
|
14
|
-
const cfg = loadConfig();
|
|
15
|
-
if (!cfg?.projects) {
|
|
16
|
-
console.log(chalk.dim('Nothing to prune.'));
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const allIosUdids = new Set(listAllIosSims().map(s => s.udid));
|
|
21
|
-
const allAvds = new Set(listAvds());
|
|
22
|
-
|
|
23
|
-
const droppedSims = [];
|
|
24
|
-
const droppedEmulators = [];
|
|
25
|
-
|
|
26
|
-
for (const [path, proj] of Object.entries(cfg.projects)) {
|
|
27
|
-
// Drop entire project if its dir is gone.
|
|
28
|
-
if (!existsSync(path)) {
|
|
29
|
-
if (opts.shutdown) {
|
|
30
|
-
if (proj.platforms?.ios?.deviceUdid) droppedSims.push(proj.platforms.ios.deviceUdid);
|
|
31
|
-
if (proj.platforms?.android?.consolePort) droppedEmulators.push(proj.platforms.android.consolePort);
|
|
32
|
-
}
|
|
33
|
-
removeProject(path);
|
|
34
|
-
console.log(chalk.yellow(`Dropped missing project: ${path}`));
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Drop iOS assignment if UDID no longer exists.
|
|
39
|
-
if (proj.platforms?.ios && !allIosUdids.has(proj.platforms.ios.deviceUdid)) {
|
|
40
|
-
clearDevice(path, 'ios');
|
|
41
|
-
console.log(chalk.dim(`${path}: cleared stale iOS assignment ${proj.platforms.ios.deviceUdid}`));
|
|
42
|
-
}
|
|
43
|
-
// Drop Android assignment if AVD no longer exists.
|
|
44
|
-
if (proj.platforms?.android && !allAvds.has(proj.platforms.android.avdName)) {
|
|
45
|
-
clearDevice(path, 'android');
|
|
46
|
-
console.log(chalk.dim(`${path}: cleared stale Android assignment ${proj.platforms.android.avdName}`));
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (opts.shutdown) {
|
|
51
|
-
for (const udid of droppedSims) shutdownIosSim(udid);
|
|
52
|
-
for (const port of droppedEmulators) shutdownAndroidEmulator(`emulator-${port}`);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
console.log(chalk.green('Prune complete.'));
|
|
56
|
-
});
|
|
57
|
-
}
|
package/src/commands/shutdown.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// src/commands/shutdown.js
|
|
2
|
-
import chalk from 'chalk';
|
|
3
|
-
import { findProjectRoot } from '../project.js';
|
|
4
|
-
import { getProject, clearDevice } from '../config.js';
|
|
5
|
-
import { shutdownIosSim } from '../sim/ios.js';
|
|
6
|
-
import { shutdownAndroidEmulator } from '../sim/android.js';
|
|
7
|
-
|
|
8
|
-
export default function shutdownCommand(program) {
|
|
9
|
-
program
|
|
10
|
-
.command('shutdown')
|
|
11
|
-
.description('Release and shut down the simulator/emulator(s) for the current project')
|
|
12
|
-
.option('--platform <platform>', 'ios or android (default: both)')
|
|
13
|
-
.action((opts) => {
|
|
14
|
-
const root = findProjectRoot(process.cwd());
|
|
15
|
-
if (!root) {
|
|
16
|
-
console.error(chalk.red('Not in a React Native project.'));
|
|
17
|
-
process.exit(1);
|
|
18
|
-
}
|
|
19
|
-
const proj = getProject(root);
|
|
20
|
-
if (!proj) {
|
|
21
|
-
console.log(chalk.dim('No project entry.'));
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
const platforms = opts.platform ? [opts.platform] : ['ios', 'android'];
|
|
25
|
-
for (const p of platforms) {
|
|
26
|
-
const entry = proj.platforms?.[p];
|
|
27
|
-
if (!entry) {
|
|
28
|
-
console.log(chalk.dim(`No ${p} assignment.`));
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
if (p === 'ios') {
|
|
32
|
-
shutdownIosSim(entry.deviceUdid);
|
|
33
|
-
console.log(chalk.green(`Shut down iOS sim ${entry.deviceUdid}`));
|
|
34
|
-
} else {
|
|
35
|
-
shutdownAndroidEmulator(`emulator-${entry.consolePort}`);
|
|
36
|
-
console.log(chalk.green(`Shut down emulator-${entry.consolePort}`));
|
|
37
|
-
}
|
|
38
|
-
clearDevice(root, p);
|
|
39
|
-
}
|
|
40
|
-
});
|
|
41
|
-
}
|
package/test/config.test.js
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import { test, beforeEach, afterEach } from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtempSync, rmSync, existsSync } from 'fs';
|
|
4
|
-
import { tmpdir } from 'os';
|
|
5
|
-
import { join } from 'path';
|
|
6
|
-
import {
|
|
7
|
-
getConfigDir,
|
|
8
|
-
loadConfig,
|
|
9
|
-
saveConfig,
|
|
10
|
-
ensureConfig,
|
|
11
|
-
getProject,
|
|
12
|
-
upsertProject,
|
|
13
|
-
removeProject,
|
|
14
|
-
setMetro,
|
|
15
|
-
setDevice,
|
|
16
|
-
clearDevice,
|
|
17
|
-
allMetroPorts,
|
|
18
|
-
allClaimedDevices,
|
|
19
|
-
addReservation,
|
|
20
|
-
removeReservation,
|
|
21
|
-
listReservations,
|
|
22
|
-
clearAllReservations,
|
|
23
|
-
findReservations,
|
|
24
|
-
recordSimUsage,
|
|
25
|
-
getSimUsage,
|
|
26
|
-
} from '../src/config.js';
|
|
27
|
-
|
|
28
|
-
let tmpHome;
|
|
29
|
-
|
|
30
|
-
beforeEach(() => {
|
|
31
|
-
tmpHome = mkdtempSync(join(tmpdir(), 'rn-iso-test-'));
|
|
32
|
-
process.env.RN_ISO_HOME = tmpHome;
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
afterEach(() => {
|
|
36
|
-
rmSync(tmpHome, { recursive: true, force: true });
|
|
37
|
-
delete process.env.RN_ISO_HOME;
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test('getConfigDir respects RN_ISO_HOME', () => {
|
|
41
|
-
assert.equal(getConfigDir(), tmpHome);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('loadConfig returns null when no file exists', () => {
|
|
45
|
-
assert.equal(loadConfig(), null);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('ensureConfig creates and returns empty config', () => {
|
|
49
|
-
const cfg = ensureConfig();
|
|
50
|
-
assert.deepEqual(cfg, { version: 1, projects: {}, reservations: { ios: [], android: [] } });
|
|
51
|
-
assert.ok(existsSync(join(tmpHome, 'config.json')));
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('saveConfig + loadConfig roundtrip', () => {
|
|
55
|
-
saveConfig({ version: 1, projects: { '/foo': { metroPort: 8082, platforms: {} } } });
|
|
56
|
-
const cfg = loadConfig();
|
|
57
|
-
assert.equal(cfg.projects['/foo'].metroPort, 8082);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('upsertProject creates a new project entry with defaults', () => {
|
|
61
|
-
const proj = upsertProject('/abs/path', {
|
|
62
|
-
bundleId: 'com.foo',
|
|
63
|
-
androidPackage: 'com.foo',
|
|
64
|
-
isExpo: true,
|
|
65
|
-
});
|
|
66
|
-
assert.equal(proj.bundleId, 'com.foo');
|
|
67
|
-
assert.equal(proj.metroPort, null);
|
|
68
|
-
assert.deepEqual(proj.platforms, {});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('upsertProject preserves existing fields when called again', () => {
|
|
72
|
-
upsertProject('/p', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
73
|
-
setMetro('/p', 8082, 12345);
|
|
74
|
-
upsertProject('/p', { bundleId: 'com.b', androidPackage: 'com.b', isExpo: false });
|
|
75
|
-
const proj = getProject('/p');
|
|
76
|
-
assert.equal(proj.bundleId, 'com.b');
|
|
77
|
-
assert.equal(proj.metroPort, 8082);
|
|
78
|
-
assert.equal(proj.metroPid, 12345);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('setDevice and clearDevice mutate platforms', () => {
|
|
82
|
-
upsertProject('/p', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
83
|
-
setDevice('/p', 'ios', { deviceUdid: 'ABC' });
|
|
84
|
-
assert.equal(getProject('/p').platforms.ios.deviceUdid, 'ABC');
|
|
85
|
-
clearDevice('/p', 'ios');
|
|
86
|
-
assert.equal(getProject('/p').platforms.ios, undefined);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test('allMetroPorts collects ports from all projects', () => {
|
|
90
|
-
upsertProject('/a', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
91
|
-
upsertProject('/b', { bundleId: 'com.b', androidPackage: 'com.b', isExpo: false });
|
|
92
|
-
setMetro('/a', 8082, null);
|
|
93
|
-
setMetro('/b', 8083, null);
|
|
94
|
-
assert.deepEqual(allMetroPorts().sort(), [8082, 8083]);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test('allClaimedDevices returns udids and avd names across projects', () => {
|
|
98
|
-
upsertProject('/a', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
99
|
-
upsertProject('/b', { bundleId: 'com.b', androidPackage: 'com.b', isExpo: false });
|
|
100
|
-
setDevice('/a', 'ios', { deviceUdid: 'UDID-1' });
|
|
101
|
-
setDevice('/b', 'android', { avdName: 'Pixel_6', consolePort: 5554 });
|
|
102
|
-
const claimed = allClaimedDevices();
|
|
103
|
-
assert.deepEqual(claimed.iosUdids, ['UDID-1']);
|
|
104
|
-
assert.deepEqual(claimed.androidAvds, ['Pixel_6']);
|
|
105
|
-
assert.deepEqual(claimed.androidConsolePorts, [5554]);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test('removeProject deletes entry', () => {
|
|
109
|
-
upsertProject('/p', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
110
|
-
removeProject('/p');
|
|
111
|
-
assert.equal(getProject('/p'), null);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test('addReservation appends an iOS reservation and idempotently updates by UDID', () => {
|
|
115
|
-
addReservation('ios', { udid: 'UDID-X', label: 'agent-1' });
|
|
116
|
-
addReservation('ios', { udid: 'UDID-Y' });
|
|
117
|
-
let r = listReservations();
|
|
118
|
-
assert.equal(r.ios.length, 2);
|
|
119
|
-
// Re-add with same UDID -> updated, not duplicated
|
|
120
|
-
addReservation('ios', { udid: 'UDID-X', label: 'agent-1-renamed' });
|
|
121
|
-
r = listReservations();
|
|
122
|
-
assert.equal(r.ios.length, 2);
|
|
123
|
-
assert.equal(r.ios.find(e => e.udid === 'UDID-X').label, 'agent-1-renamed');
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test('addReservation works for android keyed by serial', () => {
|
|
127
|
-
addReservation('android', { serial: 'emulator-5554', consolePort: 5554, avdName: 'Pixel_6' });
|
|
128
|
-
const r = listReservations();
|
|
129
|
-
assert.equal(r.android.length, 1);
|
|
130
|
-
assert.equal(r.android[0].avdName, 'Pixel_6');
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test('removeReservation drops the matching entry', () => {
|
|
134
|
-
addReservation('ios', { udid: 'UDID-1' });
|
|
135
|
-
addReservation('ios', { udid: 'UDID-2' });
|
|
136
|
-
const removed = removeReservation('ios', 'UDID-1');
|
|
137
|
-
assert.equal(removed, true);
|
|
138
|
-
assert.deepEqual(listReservations().ios.map(e => e.udid), ['UDID-2']);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test('allClaimedDevices includes reservations alongside project assignments', () => {
|
|
142
|
-
upsertProject('/p', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
143
|
-
setDevice('/p', 'ios', { deviceUdid: 'UDID-PROJECT' });
|
|
144
|
-
addReservation('ios', { udid: 'UDID-EXTERNAL', label: 'agent-1' });
|
|
145
|
-
addReservation('android', { serial: 'emulator-5554', consolePort: 5554, avdName: 'Pixel_6' });
|
|
146
|
-
const claimed = allClaimedDevices();
|
|
147
|
-
assert.deepEqual(claimed.iosUdids.sort(), ['UDID-EXTERNAL', 'UDID-PROJECT']);
|
|
148
|
-
assert.deepEqual(claimed.androidAvds, ['Pixel_6']);
|
|
149
|
-
assert.deepEqual(claimed.androidConsolePorts, [5554]);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test('clearAllReservations empties both platforms', () => {
|
|
153
|
-
addReservation('ios', { udid: 'UDID-1' });
|
|
154
|
-
addReservation('android', { serial: 'emulator-5554', consolePort: 5554 });
|
|
155
|
-
clearAllReservations();
|
|
156
|
-
const r = listReservations();
|
|
157
|
-
assert.deepEqual(r, { ios: [], android: [] });
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test('findReservations matches by id (UDID for iOS, serial for Android)', () => {
|
|
161
|
-
addReservation('ios', { udid: 'UDID-1', label: 'agent-2' });
|
|
162
|
-
addReservation('android', { serial: 'emulator-5554', consolePort: 5554, label: 'agent-1' });
|
|
163
|
-
const ios = findReservations('UDID-1');
|
|
164
|
-
assert.deepEqual(ios, [{ platform: 'ios', id: 'UDID-1', label: 'agent-2' }]);
|
|
165
|
-
const android = findReservations('emulator-5554');
|
|
166
|
-
assert.deepEqual(android, [{ platform: 'android', id: 'emulator-5554', label: 'agent-1' }]);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test('findReservations matches by label across platforms', () => {
|
|
170
|
-
addReservation('ios', { udid: 'UDID-1', label: 'shared-label' });
|
|
171
|
-
addReservation('android', { serial: 'emulator-5554', consolePort: 5554, label: 'shared-label' });
|
|
172
|
-
const matches = findReservations('shared-label');
|
|
173
|
-
assert.equal(matches.length, 2);
|
|
174
|
-
assert.deepEqual(matches.map(m => m.platform).sort(), ['android', 'ios']);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test('findReservations respects platform filter', () => {
|
|
178
|
-
addReservation('ios', { udid: 'UDID-1', label: 'shared' });
|
|
179
|
-
addReservation('android', { serial: 'emulator-5554', consolePort: 5554, label: 'shared' });
|
|
180
|
-
const matches = findReservations('shared', 'android');
|
|
181
|
-
assert.equal(matches.length, 1);
|
|
182
|
-
assert.equal(matches[0].platform, 'android');
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test('findReservations returns empty when nothing matches', () => {
|
|
186
|
-
addReservation('ios', { udid: 'UDID-1', label: 'agent-1' });
|
|
187
|
-
assert.deepEqual(findReservations('nope'), []);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test('recordSimUsage increments and getSimUsage reads counts', () => {
|
|
191
|
-
recordSimUsage('ios', 'UDID-A');
|
|
192
|
-
recordSimUsage('ios', 'UDID-A');
|
|
193
|
-
recordSimUsage('ios', 'UDID-B');
|
|
194
|
-
recordSimUsage('android', 'Pixel_6');
|
|
195
|
-
const usage = getSimUsage();
|
|
196
|
-
assert.equal(usage.ios['UDID-A'], 2);
|
|
197
|
-
assert.equal(usage.ios['UDID-B'], 1);
|
|
198
|
-
assert.equal(usage.android['Pixel_6'], 1);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test('allClaimedDevices.iosClaims labels project vs reservation sources', () => {
|
|
202
|
-
upsertProject('/Users/janic/Developer/myapp', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: true });
|
|
203
|
-
setDevice('/Users/janic/Developer/myapp', 'ios', { deviceUdid: 'UDID-PROJ' });
|
|
204
|
-
addReservation('ios', { udid: 'UDID-EXT', label: 'agent-1' });
|
|
205
|
-
const claimed = allClaimedDevices();
|
|
206
|
-
assert.deepEqual(claimed.iosClaims['UDID-PROJ'], { source: 'project', label: 'myapp' });
|
|
207
|
-
assert.deepEqual(claimed.iosClaims['UDID-EXT'], { source: 'reservation', label: 'agent-1' });
|
|
208
|
-
});
|
package/test/exec.test.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { test } from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { setExecutor, getExecutor, resetExecutor } from '../src/exec.js';
|
|
4
|
-
|
|
5
|
-
test('default executor runs commands and returns stdout trimmed', () => {
|
|
6
|
-
resetExecutor();
|
|
7
|
-
const out = getExecutor().run('echo hello');
|
|
8
|
-
assert.equal(out, 'hello');
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
test('runQuiet returns null on failure', () => {
|
|
12
|
-
resetExecutor();
|
|
13
|
-
const out = getExecutor().runQuiet('false');
|
|
14
|
-
assert.equal(out, null);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test('setExecutor replaces the active executor', () => {
|
|
18
|
-
setExecutor({
|
|
19
|
-
run: () => 'mocked',
|
|
20
|
-
runQuiet: () => 'mocked-quiet',
|
|
21
|
-
spawn: () => ({ pid: 999 }),
|
|
22
|
-
});
|
|
23
|
-
assert.equal(getExecutor().run('anything'), 'mocked');
|
|
24
|
-
assert.equal(getExecutor().runQuiet('anything'), 'mocked-quiet');
|
|
25
|
-
resetExecutor();
|
|
26
|
-
});
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// Minimal pbxproj fixture mimicking a hybrid project layout: main app
|
|
2
|
-
// bundle id appears in multiple build configs; an extension target adds a
|
|
3
|
-
// suffix on its own id; macros are present too.
|
|
4
|
-
PRODUCT_BUNDLE_IDENTIFIER = me.sample;
|
|
5
|
-
PRODUCT_BUNDLE_IDENTIFIER = me.sample;
|
|
6
|
-
PRODUCT_BUNDLE_IDENTIFIER = me.sample;
|
|
7
|
-
PRODUCT_BUNDLE_IDENTIFIER = me.sample;
|
|
8
|
-
PRODUCT_BUNDLE_IDENTIFIER = me.sample.WidgetExtension;
|
|
9
|
-
PRODUCT_BUNDLE_IDENTIFIER = me.sample.WidgetExtension;
|
|
10
|
-
PRODUCT_BUNDLE_IDENTIFIER = $(SOMETHING);
|