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,2653 +0,0 @@
|
|
|
1
|
-
# rn-iso Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
-
|
|
5
|
-
**Goal:** Build a CLI (`rn-iso`) that lets multiple React Native / Expo projects (or worktrees) run concurrently, each with its own Metro server and dedicated simulator/emulator, so AI agents can target the right device without manual port/sim juggling.
|
|
6
|
-
|
|
7
|
-
**Architecture:** Node.js ESM CLI built on `commander`. Global config at `~/.rn-iso/config.json` keyed by absolute project path. Per-platform device assignment is sticky and stored in config. Process invocation goes through a single `exec` wrapper so logic is testable. Pure parsing/algorithm code is unit-tested with `node --test`; integration with real sims is verified manually.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** Node 20+, ESM modules, `commander` (CLI), `chalk` (colors), `prompts` (interactive picker). Test runner: `node --test`. No transpiler.
|
|
10
|
-
|
|
11
|
-
**Reference spec:** `docs/specs/2026-04-25-rn-iso-design.md` — defer there for any design intent ambiguity.
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
## File Structure
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
bin/cli.js # entry, dispatches to commands
|
|
19
|
-
src/
|
|
20
|
-
exec.js # single point for child_process; mockable
|
|
21
|
-
config.js # global config CRUD, schema migration
|
|
22
|
-
project.js # project root walk, bundle ID detection, Expo detection
|
|
23
|
-
ports.js # port allocation + Metro probing
|
|
24
|
-
sim/
|
|
25
|
-
ios.js # simctl wrappers, iOS device pool/allocation
|
|
26
|
-
android.js # adb/emulator wrappers, Android device pool
|
|
27
|
-
metro.js # detached Metro spawn, PID lifecycle, log files
|
|
28
|
-
runner.js # bare vs Expo dispatch (run-ios/run-android)
|
|
29
|
-
commands/
|
|
30
|
-
ios.js # `rn-iso ios`
|
|
31
|
-
android.js # `rn-iso android`
|
|
32
|
-
start.js # `rn-iso start`
|
|
33
|
-
device.js # `rn-iso device`
|
|
34
|
-
status.js # `rn-iso status`
|
|
35
|
-
release.js # `rn-iso release`
|
|
36
|
-
shutdown.js # `rn-iso shutdown`
|
|
37
|
-
prune.js # `rn-iso prune`
|
|
38
|
-
logs.js # `rn-iso logs`
|
|
39
|
-
stop.js # `rn-iso stop`
|
|
40
|
-
test/
|
|
41
|
-
config.test.js
|
|
42
|
-
project.test.js
|
|
43
|
-
ports.test.js
|
|
44
|
-
sim-ios.test.js
|
|
45
|
-
sim-android.test.js
|
|
46
|
-
runner.test.js
|
|
47
|
-
skill/
|
|
48
|
-
SKILL.md
|
|
49
|
-
package.json
|
|
50
|
-
.gitignore
|
|
51
|
-
README.md
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
Each `src/commands/*.js` is a thin commander wrapper that calls service functions in the relevant module. The bulk of testable logic lives in `src/sim/`, `src/ports.js`, `src/config.js`, `src/project.js`.
|
|
55
|
-
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
## Task 1: Project bootstrap
|
|
59
|
-
|
|
60
|
-
**Files:**
|
|
61
|
-
- Create: `package.json`
|
|
62
|
-
- Create: `.gitignore`
|
|
63
|
-
- Create: `bin/cli.js`
|
|
64
|
-
- Create: `src/index.js` (placeholder, just exports version)
|
|
65
|
-
|
|
66
|
-
- [ ] **Step 1: Create package.json**
|
|
67
|
-
|
|
68
|
-
```json
|
|
69
|
-
{
|
|
70
|
-
"name": "rn-iso",
|
|
71
|
-
"version": "0.1.0",
|
|
72
|
-
"description": "Isolated React Native dev environments per project/worktree",
|
|
73
|
-
"type": "module",
|
|
74
|
-
"bin": {
|
|
75
|
-
"rn-iso": "bin/cli.js"
|
|
76
|
-
},
|
|
77
|
-
"scripts": {
|
|
78
|
-
"test": "node --test test/*.test.js"
|
|
79
|
-
},
|
|
80
|
-
"dependencies": {
|
|
81
|
-
"chalk": "^5.4.1",
|
|
82
|
-
"commander": "^13.1.0",
|
|
83
|
-
"prompts": "^2.4.2"
|
|
84
|
-
},
|
|
85
|
-
"engines": {
|
|
86
|
-
"node": ">=20"
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
- [ ] **Step 2: Create .gitignore**
|
|
92
|
-
|
|
93
|
-
```
|
|
94
|
-
node_modules/
|
|
95
|
-
*.log
|
|
96
|
-
.DS_Store
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
- [ ] **Step 3: Install dependencies**
|
|
100
|
-
|
|
101
|
-
Run: `cd /Users/janicduplessis/Developer/rn-iso && npm install`
|
|
102
|
-
Expected: creates `node_modules/` and `package-lock.json` without errors.
|
|
103
|
-
|
|
104
|
-
- [ ] **Step 4: Create bin/cli.js with version-only stub**
|
|
105
|
-
|
|
106
|
-
```javascript
|
|
107
|
-
#!/usr/bin/env node
|
|
108
|
-
import { Command } from 'commander';
|
|
109
|
-
|
|
110
|
-
const program = new Command();
|
|
111
|
-
program
|
|
112
|
-
.name('rn-iso')
|
|
113
|
-
.description('Isolated React Native dev environments per project/worktree')
|
|
114
|
-
.version('0.1.0');
|
|
115
|
-
|
|
116
|
-
program.parse();
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
- [ ] **Step 5: Make executable and smoke-test**
|
|
120
|
-
|
|
121
|
-
Run: `chmod +x bin/cli.js && node bin/cli.js --version`
|
|
122
|
-
Expected output: `0.1.0`
|
|
123
|
-
|
|
124
|
-
- [ ] **Step 6: Commit**
|
|
125
|
-
|
|
126
|
-
```bash
|
|
127
|
-
git add package.json package-lock.json .gitignore bin/cli.js
|
|
128
|
-
git commit -m "chore: project bootstrap with commander stub"
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
---
|
|
132
|
-
|
|
133
|
-
## Task 2: Exec wrapper
|
|
134
|
-
|
|
135
|
-
The single point all `child_process` calls go through. Tests inject a mock to avoid real shell-outs.
|
|
136
|
-
|
|
137
|
-
**Files:**
|
|
138
|
-
- Create: `src/exec.js`
|
|
139
|
-
- Create: `test/exec.test.js`
|
|
140
|
-
|
|
141
|
-
- [ ] **Step 1: Write the failing test**
|
|
142
|
-
|
|
143
|
-
```javascript
|
|
144
|
-
// test/exec.test.js
|
|
145
|
-
import { test } from 'node:test';
|
|
146
|
-
import assert from 'node:assert/strict';
|
|
147
|
-
import { setExecutor, getExecutor, resetExecutor } from '../src/exec.js';
|
|
148
|
-
|
|
149
|
-
test('default executor runs commands and returns stdout trimmed', () => {
|
|
150
|
-
resetExecutor();
|
|
151
|
-
const out = getExecutor().run('echo hello');
|
|
152
|
-
assert.equal(out, 'hello');
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test('runQuiet returns null on failure', () => {
|
|
156
|
-
resetExecutor();
|
|
157
|
-
const out = getExecutor().runQuiet('false');
|
|
158
|
-
assert.equal(out, null);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test('setExecutor replaces the active executor', () => {
|
|
162
|
-
setExecutor({
|
|
163
|
-
run: () => 'mocked',
|
|
164
|
-
runQuiet: () => 'mocked-quiet',
|
|
165
|
-
spawn: () => ({ pid: 999 }),
|
|
166
|
-
});
|
|
167
|
-
assert.equal(getExecutor().run('anything'), 'mocked');
|
|
168
|
-
assert.equal(getExecutor().runQuiet('anything'), 'mocked-quiet');
|
|
169
|
-
resetExecutor();
|
|
170
|
-
});
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
174
|
-
|
|
175
|
-
Run: `npm test -- test/exec.test.js`
|
|
176
|
-
Expected: FAIL — module not found.
|
|
177
|
-
|
|
178
|
-
- [ ] **Step 3: Implement src/exec.js**
|
|
179
|
-
|
|
180
|
-
```javascript
|
|
181
|
-
// src/exec.js
|
|
182
|
-
import { execSync, spawn } from 'child_process';
|
|
183
|
-
|
|
184
|
-
const defaultExecutor = {
|
|
185
|
-
run(cmd) {
|
|
186
|
-
return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
187
|
-
},
|
|
188
|
-
runQuiet(cmd) {
|
|
189
|
-
try {
|
|
190
|
-
return this.run(cmd);
|
|
191
|
-
} catch {
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
|
-
},
|
|
195
|
-
spawn(cmd, args, opts) {
|
|
196
|
-
return spawn(cmd, args, opts);
|
|
197
|
-
},
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
let active = defaultExecutor;
|
|
201
|
-
|
|
202
|
-
export function setExecutor(e) {
|
|
203
|
-
active = e;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export function resetExecutor() {
|
|
207
|
-
active = defaultExecutor;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export function getExecutor() {
|
|
211
|
-
return active;
|
|
212
|
-
}
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
216
|
-
|
|
217
|
-
Run: `npm test -- test/exec.test.js`
|
|
218
|
-
Expected: PASS (3 tests).
|
|
219
|
-
|
|
220
|
-
- [ ] **Step 5: Commit**
|
|
221
|
-
|
|
222
|
-
```bash
|
|
223
|
-
git add src/exec.js test/exec.test.js
|
|
224
|
-
git commit -m "feat: mockable exec wrapper for child_process calls"
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
---
|
|
228
|
-
|
|
229
|
-
## Task 3: Project root + bundle ID detection
|
|
230
|
-
|
|
231
|
-
**Files:**
|
|
232
|
-
- Create: `src/project.js`
|
|
233
|
-
- Create: `test/project.test.js`
|
|
234
|
-
- Create: `test/fixtures/sample-expo-project/package.json` (test fixture)
|
|
235
|
-
- Create: `test/fixtures/sample-expo-project/app.json` (test fixture)
|
|
236
|
-
|
|
237
|
-
- [ ] **Step 1: Create test fixtures**
|
|
238
|
-
|
|
239
|
-
`test/fixtures/sample-expo-project/package.json`:
|
|
240
|
-
```json
|
|
241
|
-
{
|
|
242
|
-
"name": "sample-app",
|
|
243
|
-
"dependencies": { "expo": "~50.0.0" }
|
|
244
|
-
}
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
`test/fixtures/sample-expo-project/app.json`:
|
|
248
|
-
```json
|
|
249
|
-
{
|
|
250
|
-
"expo": {
|
|
251
|
-
"ios": { "bundleIdentifier": "com.example.sample" },
|
|
252
|
-
"android": { "package": "com.example.sample" }
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
Also create `test/fixtures/sample-bare-project/package.json`:
|
|
258
|
-
```json
|
|
259
|
-
{
|
|
260
|
-
"name": "bare-app",
|
|
261
|
-
"dependencies": { "react-native": "0.74.0" }
|
|
262
|
-
}
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
And create `test/fixtures/sample-expo-project/src/.keep` (empty file) to allow walking up from a nested dir during testing:
|
|
266
|
-
```
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
- [ ] **Step 2: Write failing tests**
|
|
270
|
-
|
|
271
|
-
```javascript
|
|
272
|
-
// test/project.test.js
|
|
273
|
-
import { test } from 'node:test';
|
|
274
|
-
import assert from 'node:assert/strict';
|
|
275
|
-
import { resolve, join } from 'path';
|
|
276
|
-
import { findProjectRoot, detectIsExpo, detectBundleId, detectAndroidPackage } from '../src/project.js';
|
|
277
|
-
|
|
278
|
-
const FIXTURES = resolve(import.meta.dirname, 'fixtures');
|
|
279
|
-
const EXPO_PROJ = join(FIXTURES, 'sample-expo-project');
|
|
280
|
-
const BARE_PROJ = join(FIXTURES, 'sample-bare-project');
|
|
281
|
-
|
|
282
|
-
test('findProjectRoot walks up from cwd to find package.json', () => {
|
|
283
|
-
const nested = join(EXPO_PROJ, 'src');
|
|
284
|
-
assert.equal(findProjectRoot(nested), EXPO_PROJ);
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
test('findProjectRoot returns null when no package.json found', () => {
|
|
288
|
-
assert.equal(findProjectRoot('/'), null);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
test('detectIsExpo true when expo is in dependencies', () => {
|
|
292
|
-
assert.equal(detectIsExpo(EXPO_PROJ), true);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
test('detectIsExpo false when expo is not in dependencies', () => {
|
|
296
|
-
assert.equal(detectIsExpo(BARE_PROJ), false);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
test('detectBundleId reads ios.bundleIdentifier from app.json', () => {
|
|
300
|
-
assert.equal(detectBundleId(EXPO_PROJ), 'com.example.sample');
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
test('detectBundleId returns null when app.json absent', () => {
|
|
304
|
-
assert.equal(detectBundleId(BARE_PROJ), null);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
test('detectAndroidPackage reads android.package from app.json', () => {
|
|
308
|
-
assert.equal(detectAndroidPackage(EXPO_PROJ), 'com.example.sample');
|
|
309
|
-
});
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
- [ ] **Step 3: Run tests to verify they fail**
|
|
313
|
-
|
|
314
|
-
Run: `npm test -- test/project.test.js`
|
|
315
|
-
Expected: FAIL — module not found.
|
|
316
|
-
|
|
317
|
-
- [ ] **Step 4: Implement src/project.js**
|
|
318
|
-
|
|
319
|
-
```javascript
|
|
320
|
-
// src/project.js
|
|
321
|
-
import { existsSync, readFileSync } from 'fs';
|
|
322
|
-
import { join, dirname, resolve } from 'path';
|
|
323
|
-
|
|
324
|
-
export function findProjectRoot(startDir) {
|
|
325
|
-
let dir = resolve(startDir);
|
|
326
|
-
while (true) {
|
|
327
|
-
if (existsSync(join(dir, 'package.json'))) return dir;
|
|
328
|
-
const parent = dirname(dir);
|
|
329
|
-
if (parent === dir) return null;
|
|
330
|
-
dir = parent;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function readPackageJson(projectRoot) {
|
|
335
|
-
const p = join(projectRoot, 'package.json');
|
|
336
|
-
if (!existsSync(p)) return null;
|
|
337
|
-
try {
|
|
338
|
-
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
339
|
-
} catch {
|
|
340
|
-
return null;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
export function detectIsExpo(projectRoot) {
|
|
345
|
-
const pkg = readPackageJson(projectRoot);
|
|
346
|
-
if (!pkg) return false;
|
|
347
|
-
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
348
|
-
return 'expo' in deps;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function readAppJson(projectRoot) {
|
|
352
|
-
const p = join(projectRoot, 'app.json');
|
|
353
|
-
if (!existsSync(p)) return null;
|
|
354
|
-
try {
|
|
355
|
-
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
356
|
-
} catch {
|
|
357
|
-
return null;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function readAppConfigText(projectRoot) {
|
|
362
|
-
for (const name of ['app.config.js', 'app.config.ts', 'app.config.cjs', 'app.config.mjs']) {
|
|
363
|
-
const p = join(projectRoot, name);
|
|
364
|
-
if (existsSync(p)) {
|
|
365
|
-
try {
|
|
366
|
-
return readFileSync(p, 'utf-8');
|
|
367
|
-
} catch { /* ignore */ }
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
return null;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
export function detectBundleId(projectRoot) {
|
|
374
|
-
const appJson = readAppJson(projectRoot);
|
|
375
|
-
const fromJson = appJson?.expo?.ios?.bundleIdentifier;
|
|
376
|
-
if (fromJson) return fromJson;
|
|
377
|
-
|
|
378
|
-
const text = readAppConfigText(projectRoot);
|
|
379
|
-
if (text) {
|
|
380
|
-
const m = text.match(/bundleIdentifier\s*:\s*["']([^"']+)["']/);
|
|
381
|
-
if (m) return m[1];
|
|
382
|
-
}
|
|
383
|
-
return null;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
export function detectAndroidPackage(projectRoot) {
|
|
387
|
-
const appJson = readAppJson(projectRoot);
|
|
388
|
-
const fromJson = appJson?.expo?.android?.package;
|
|
389
|
-
if (fromJson) return fromJson;
|
|
390
|
-
|
|
391
|
-
const text = readAppConfigText(projectRoot);
|
|
392
|
-
if (text) {
|
|
393
|
-
const m = text.match(/package\s*:\s*["']([^"']+)["']/);
|
|
394
|
-
if (m) return m[1];
|
|
395
|
-
}
|
|
396
|
-
return null;
|
|
397
|
-
}
|
|
398
|
-
```
|
|
399
|
-
|
|
400
|
-
- [ ] **Step 5: Run tests to verify they pass**
|
|
401
|
-
|
|
402
|
-
Run: `npm test -- test/project.test.js`
|
|
403
|
-
Expected: PASS (7 tests).
|
|
404
|
-
|
|
405
|
-
- [ ] **Step 6: Commit**
|
|
406
|
-
|
|
407
|
-
```bash
|
|
408
|
-
git add src/project.js test/project.test.js test/fixtures/
|
|
409
|
-
git commit -m "feat: project root + bundle ID + Expo detection"
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
---
|
|
413
|
-
|
|
414
|
-
## Task 4: Config module
|
|
415
|
-
|
|
416
|
-
**Files:**
|
|
417
|
-
- Create: `src/config.js`
|
|
418
|
-
- Create: `test/config.test.js`
|
|
419
|
-
|
|
420
|
-
- [ ] **Step 1: Write failing tests**
|
|
421
|
-
|
|
422
|
-
```javascript
|
|
423
|
-
// test/config.test.js
|
|
424
|
-
import { test, beforeEach, afterEach } from 'node:test';
|
|
425
|
-
import assert from 'node:assert/strict';
|
|
426
|
-
import { mkdtempSync, rmSync, existsSync } from 'fs';
|
|
427
|
-
import { tmpdir } from 'os';
|
|
428
|
-
import { join } from 'path';
|
|
429
|
-
import {
|
|
430
|
-
getConfigDir,
|
|
431
|
-
loadConfig,
|
|
432
|
-
saveConfig,
|
|
433
|
-
ensureConfig,
|
|
434
|
-
getProject,
|
|
435
|
-
upsertProject,
|
|
436
|
-
removeProject,
|
|
437
|
-
setMetro,
|
|
438
|
-
setDevice,
|
|
439
|
-
clearDevice,
|
|
440
|
-
allMetroPorts,
|
|
441
|
-
allClaimedDevices,
|
|
442
|
-
} from '../src/config.js';
|
|
443
|
-
|
|
444
|
-
let tmpHome;
|
|
445
|
-
|
|
446
|
-
beforeEach(() => {
|
|
447
|
-
tmpHome = mkdtempSync(join(tmpdir(), 'rn-iso-test-'));
|
|
448
|
-
process.env.RN_ISO_HOME = tmpHome;
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
afterEach(() => {
|
|
452
|
-
rmSync(tmpHome, { recursive: true, force: true });
|
|
453
|
-
delete process.env.RN_ISO_HOME;
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
test('getConfigDir respects RN_ISO_HOME', () => {
|
|
457
|
-
assert.equal(getConfigDir(), tmpHome);
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
test('loadConfig returns null when no file exists', () => {
|
|
461
|
-
assert.equal(loadConfig(), null);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
test('ensureConfig creates and returns empty config', () => {
|
|
465
|
-
const cfg = ensureConfig();
|
|
466
|
-
assert.deepEqual(cfg, { version: 1, projects: {} });
|
|
467
|
-
assert.ok(existsSync(join(tmpHome, 'config.json')));
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
test('saveConfig + loadConfig roundtrip', () => {
|
|
471
|
-
saveConfig({ version: 1, projects: { '/foo': { metroPort: 8082, platforms: {} } } });
|
|
472
|
-
const cfg = loadConfig();
|
|
473
|
-
assert.equal(cfg.projects['/foo'].metroPort, 8082);
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
test('upsertProject creates a new project entry with defaults', () => {
|
|
477
|
-
const proj = upsertProject('/abs/path', {
|
|
478
|
-
bundleId: 'com.foo',
|
|
479
|
-
androidPackage: 'com.foo',
|
|
480
|
-
isExpo: true,
|
|
481
|
-
});
|
|
482
|
-
assert.equal(proj.bundleId, 'com.foo');
|
|
483
|
-
assert.equal(proj.metroPort, null);
|
|
484
|
-
assert.deepEqual(proj.platforms, {});
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
test('upsertProject preserves existing fields when called again', () => {
|
|
488
|
-
upsertProject('/p', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
489
|
-
setMetro('/p', 8082, 12345);
|
|
490
|
-
upsertProject('/p', { bundleId: 'com.b', androidPackage: 'com.b', isExpo: false });
|
|
491
|
-
const proj = getProject('/p');
|
|
492
|
-
assert.equal(proj.bundleId, 'com.b');
|
|
493
|
-
assert.equal(proj.metroPort, 8082);
|
|
494
|
-
assert.equal(proj.metroPid, 12345);
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
test('setDevice and clearDevice mutate platforms', () => {
|
|
498
|
-
upsertProject('/p', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
499
|
-
setDevice('/p', 'ios', { deviceUdid: 'ABC' });
|
|
500
|
-
assert.equal(getProject('/p').platforms.ios.deviceUdid, 'ABC');
|
|
501
|
-
clearDevice('/p', 'ios');
|
|
502
|
-
assert.equal(getProject('/p').platforms.ios, undefined);
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
test('allMetroPorts collects ports from all projects', () => {
|
|
506
|
-
upsertProject('/a', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
507
|
-
upsertProject('/b', { bundleId: 'com.b', androidPackage: 'com.b', isExpo: false });
|
|
508
|
-
setMetro('/a', 8082, null);
|
|
509
|
-
setMetro('/b', 8083, null);
|
|
510
|
-
assert.deepEqual(allMetroPorts().sort(), [8082, 8083]);
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
test('allClaimedDevices returns udids and avd names across projects', () => {
|
|
514
|
-
upsertProject('/a', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
515
|
-
upsertProject('/b', { bundleId: 'com.b', androidPackage: 'com.b', isExpo: false });
|
|
516
|
-
setDevice('/a', 'ios', { deviceUdid: 'UDID-1' });
|
|
517
|
-
setDevice('/b', 'android', { avdName: 'Pixel_6', consolePort: 5554 });
|
|
518
|
-
const claimed = allClaimedDevices();
|
|
519
|
-
assert.deepEqual(claimed.iosUdids, ['UDID-1']);
|
|
520
|
-
assert.deepEqual(claimed.androidAvds, ['Pixel_6']);
|
|
521
|
-
assert.deepEqual(claimed.androidConsolePorts, [5554]);
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
test('removeProject deletes entry', () => {
|
|
525
|
-
upsertProject('/p', { bundleId: 'com.a', androidPackage: 'com.a', isExpo: false });
|
|
526
|
-
removeProject('/p');
|
|
527
|
-
assert.equal(getProject('/p'), null);
|
|
528
|
-
});
|
|
529
|
-
```
|
|
530
|
-
|
|
531
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
532
|
-
|
|
533
|
-
Run: `npm test -- test/config.test.js`
|
|
534
|
-
Expected: FAIL — module not found.
|
|
535
|
-
|
|
536
|
-
- [ ] **Step 3: Implement src/config.js**
|
|
537
|
-
|
|
538
|
-
```javascript
|
|
539
|
-
// src/config.js
|
|
540
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
541
|
-
import { join } from 'path';
|
|
542
|
-
import { homedir } from 'os';
|
|
543
|
-
|
|
544
|
-
export function getConfigDir() {
|
|
545
|
-
return process.env.RN_ISO_HOME || join(homedir(), '.rn-iso');
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function getConfigPath() {
|
|
549
|
-
return join(getConfigDir(), 'config.json');
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function ensureDir() {
|
|
553
|
-
const dir = getConfigDir();
|
|
554
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
export function loadConfig() {
|
|
558
|
-
const p = getConfigPath();
|
|
559
|
-
if (!existsSync(p)) return null;
|
|
560
|
-
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
export function saveConfig(config) {
|
|
564
|
-
ensureDir();
|
|
565
|
-
writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n');
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
export function ensureConfig() {
|
|
569
|
-
const existing = loadConfig();
|
|
570
|
-
if (existing) return existing;
|
|
571
|
-
const fresh = { version: 1, projects: {} };
|
|
572
|
-
saveConfig(fresh);
|
|
573
|
-
return fresh;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
export function getProject(projectPath) {
|
|
577
|
-
const cfg = loadConfig();
|
|
578
|
-
return cfg?.projects?.[projectPath] || null;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
export function upsertProject(projectPath, fields) {
|
|
582
|
-
const cfg = ensureConfig();
|
|
583
|
-
const existing = cfg.projects[projectPath] || {
|
|
584
|
-
metroPort: null,
|
|
585
|
-
metroPid: null,
|
|
586
|
-
platforms: {},
|
|
587
|
-
};
|
|
588
|
-
cfg.projects[projectPath] = {
|
|
589
|
-
...existing,
|
|
590
|
-
...fields,
|
|
591
|
-
};
|
|
592
|
-
saveConfig(cfg);
|
|
593
|
-
return cfg.projects[projectPath];
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
export function removeProject(projectPath) {
|
|
597
|
-
const cfg = loadConfig();
|
|
598
|
-
if (!cfg?.projects?.[projectPath]) return;
|
|
599
|
-
delete cfg.projects[projectPath];
|
|
600
|
-
saveConfig(cfg);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
export function setMetro(projectPath, metroPort, metroPid) {
|
|
604
|
-
const cfg = ensureConfig();
|
|
605
|
-
if (!cfg.projects[projectPath]) {
|
|
606
|
-
throw new Error(`Project not registered: ${projectPath}`);
|
|
607
|
-
}
|
|
608
|
-
cfg.projects[projectPath].metroPort = metroPort;
|
|
609
|
-
cfg.projects[projectPath].metroPid = metroPid;
|
|
610
|
-
saveConfig(cfg);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
export function setDevice(projectPath, platform, deviceFields) {
|
|
614
|
-
const cfg = ensureConfig();
|
|
615
|
-
if (!cfg.projects[projectPath]) {
|
|
616
|
-
throw new Error(`Project not registered: ${projectPath}`);
|
|
617
|
-
}
|
|
618
|
-
cfg.projects[projectPath].platforms = cfg.projects[projectPath].platforms || {};
|
|
619
|
-
cfg.projects[projectPath].platforms[platform] = deviceFields;
|
|
620
|
-
saveConfig(cfg);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
export function clearDevice(projectPath, platform) {
|
|
624
|
-
const cfg = loadConfig();
|
|
625
|
-
if (!cfg?.projects?.[projectPath]?.platforms) return;
|
|
626
|
-
delete cfg.projects[projectPath].platforms[platform];
|
|
627
|
-
saveConfig(cfg);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
export function allMetroPorts() {
|
|
631
|
-
const cfg = loadConfig();
|
|
632
|
-
if (!cfg?.projects) return [];
|
|
633
|
-
return Object.values(cfg.projects)
|
|
634
|
-
.map(p => p.metroPort)
|
|
635
|
-
.filter(p => typeof p === 'number');
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
export function allClaimedDevices() {
|
|
639
|
-
const cfg = loadConfig();
|
|
640
|
-
const result = { iosUdids: [], androidAvds: [], androidConsolePorts: [] };
|
|
641
|
-
if (!cfg?.projects) return result;
|
|
642
|
-
for (const proj of Object.values(cfg.projects)) {
|
|
643
|
-
const ios = proj.platforms?.ios;
|
|
644
|
-
if (ios?.deviceUdid) result.iosUdids.push(ios.deviceUdid);
|
|
645
|
-
const android = proj.platforms?.android;
|
|
646
|
-
if (android?.avdName) result.androidAvds.push(android.avdName);
|
|
647
|
-
if (typeof android?.consolePort === 'number') result.androidConsolePorts.push(android.consolePort);
|
|
648
|
-
}
|
|
649
|
-
return result;
|
|
650
|
-
}
|
|
651
|
-
```
|
|
652
|
-
|
|
653
|
-
- [ ] **Step 4: Run tests to verify they pass**
|
|
654
|
-
|
|
655
|
-
Run: `npm test -- test/config.test.js`
|
|
656
|
-
Expected: PASS (10 tests).
|
|
657
|
-
|
|
658
|
-
- [ ] **Step 5: Commit**
|
|
659
|
-
|
|
660
|
-
```bash
|
|
661
|
-
git add src/config.js test/config.test.js
|
|
662
|
-
git commit -m "feat: global config module with per-project state"
|
|
663
|
-
```
|
|
664
|
-
|
|
665
|
-
---
|
|
666
|
-
|
|
667
|
-
## Task 5: Port allocation
|
|
668
|
-
|
|
669
|
-
**Files:**
|
|
670
|
-
- Create: `src/ports.js`
|
|
671
|
-
- Create: `test/ports.test.js`
|
|
672
|
-
|
|
673
|
-
- [ ] **Step 1: Write failing tests**
|
|
674
|
-
|
|
675
|
-
```javascript
|
|
676
|
-
// test/ports.test.js
|
|
677
|
-
import { test, beforeEach, afterEach } from 'node:test';
|
|
678
|
-
import assert from 'node:assert/strict';
|
|
679
|
-
import { mkdtempSync, rmSync } from 'fs';
|
|
680
|
-
import { tmpdir } from 'os';
|
|
681
|
-
import { join } from 'path';
|
|
682
|
-
import { resetExecutor } from '../src/exec.js';
|
|
683
|
-
import { upsertProject, setMetro } from '../src/config.js';
|
|
684
|
-
import { computeNextPort, findReclaimablePort, allocatePort } from '../src/ports.js';
|
|
685
|
-
|
|
686
|
-
let tmpHome;
|
|
687
|
-
|
|
688
|
-
beforeEach(() => {
|
|
689
|
-
tmpHome = mkdtempSync(join(tmpdir(), 'rn-iso-test-'));
|
|
690
|
-
process.env.RN_ISO_HOME = tmpHome;
|
|
691
|
-
});
|
|
692
|
-
|
|
693
|
-
afterEach(() => {
|
|
694
|
-
rmSync(tmpHome, { recursive: true, force: true });
|
|
695
|
-
delete process.env.RN_ISO_HOME;
|
|
696
|
-
resetExecutor();
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
test('computeNextPort returns 8082 with no existing ports', () => {
|
|
700
|
-
assert.equal(computeNextPort(), 8082);
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
test('computeNextPort returns max + 1', () => {
|
|
704
|
-
upsertProject('/a', { bundleId: 'a', androidPackage: 'a', isExpo: false });
|
|
705
|
-
upsertProject('/b', { bundleId: 'b', androidPackage: 'b', isExpo: false });
|
|
706
|
-
setMetro('/a', 8082, null);
|
|
707
|
-
setMetro('/b', 8090, null);
|
|
708
|
-
assert.equal(computeNextPort(), 8091);
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
test('findReclaimablePort returns null when no projects', async () => {
|
|
712
|
-
const r = await findReclaimablePort('/excluded');
|
|
713
|
-
assert.equal(r, null);
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
test('findReclaimablePort skips the excluded project path', async () => {
|
|
717
|
-
upsertProject('/a', { bundleId: 'a', androidPackage: 'a', isExpo: false });
|
|
718
|
-
setMetro('/a', 8082, null);
|
|
719
|
-
// Mock isMetroRunning to always return false (port is dead)
|
|
720
|
-
const r = await findReclaimablePort('/a', async () => false);
|
|
721
|
-
assert.equal(r, null);
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
test('findReclaimablePort returns first dead port and its owner', async () => {
|
|
725
|
-
upsertProject('/a', { bundleId: 'a', androidPackage: 'a', isExpo: false });
|
|
726
|
-
upsertProject('/b', { bundleId: 'b', androidPackage: 'b', isExpo: false });
|
|
727
|
-
setMetro('/a', 8082, null);
|
|
728
|
-
setMetro('/b', 8083, null);
|
|
729
|
-
// 8082 alive, 8083 dead
|
|
730
|
-
const probe = async (port) => port === 8082;
|
|
731
|
-
const r = await findReclaimablePort('/c', probe);
|
|
732
|
-
assert.deepEqual(r, { port: 8083, ownerPath: '/b' });
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
test('allocatePort reclaims dead ports and removes the dead project', async () => {
|
|
736
|
-
upsertProject('/a', { bundleId: 'a', androidPackage: 'a', isExpo: false });
|
|
737
|
-
setMetro('/a', 8082, null);
|
|
738
|
-
const probe = async () => false;
|
|
739
|
-
const port = await allocatePort('/new', probe);
|
|
740
|
-
assert.equal(port, 8082);
|
|
741
|
-
// Caller should have removed /a — verify via behavior
|
|
742
|
-
const { getProject } = await import('../src/config.js');
|
|
743
|
-
assert.equal(getProject('/a'), null);
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
test('allocatePort assigns a fresh port when nothing is reclaimable', async () => {
|
|
747
|
-
upsertProject('/a', { bundleId: 'a', androidPackage: 'a', isExpo: false });
|
|
748
|
-
setMetro('/a', 8082, null);
|
|
749
|
-
const probe = async () => true; // everything alive
|
|
750
|
-
const port = await allocatePort('/new', probe);
|
|
751
|
-
assert.equal(port, 8083);
|
|
752
|
-
});
|
|
753
|
-
```
|
|
754
|
-
|
|
755
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
756
|
-
|
|
757
|
-
Run: `npm test -- test/ports.test.js`
|
|
758
|
-
Expected: FAIL — module not found.
|
|
759
|
-
|
|
760
|
-
- [ ] **Step 3: Implement src/ports.js**
|
|
761
|
-
|
|
762
|
-
```javascript
|
|
763
|
-
// src/ports.js
|
|
764
|
-
import { request } from 'http';
|
|
765
|
-
import { loadConfig, allMetroPorts, removeProject } from './config.js';
|
|
766
|
-
|
|
767
|
-
export function isMetroRunning(port) {
|
|
768
|
-
return new Promise((resolve) => {
|
|
769
|
-
const req = request(
|
|
770
|
-
{ hostname: 'localhost', port, path: '/status', timeout: 2000 },
|
|
771
|
-
(res) => {
|
|
772
|
-
let data = '';
|
|
773
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
774
|
-
res.on('end', () => resolve(data.includes('packager-status:running')));
|
|
775
|
-
}
|
|
776
|
-
);
|
|
777
|
-
req.on('error', () => resolve(false));
|
|
778
|
-
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
779
|
-
req.end();
|
|
780
|
-
});
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
export function computeNextPort() {
|
|
784
|
-
const ports = allMetroPorts();
|
|
785
|
-
if (ports.length === 0) return 8082;
|
|
786
|
-
return Math.max(...ports, 8081) + 1;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
export async function findReclaimablePort(excludeProjectPath, probe = isMetroRunning) {
|
|
790
|
-
const cfg = loadConfig();
|
|
791
|
-
if (!cfg?.projects) return null;
|
|
792
|
-
const candidates = [];
|
|
793
|
-
for (const [path, proj] of Object.entries(cfg.projects)) {
|
|
794
|
-
if (path === excludeProjectPath) continue;
|
|
795
|
-
if (typeof proj.metroPort === 'number') {
|
|
796
|
-
candidates.push({ port: proj.metroPort, ownerPath: path });
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
for (const c of candidates) {
|
|
800
|
-
const alive = await probe(c.port);
|
|
801
|
-
if (!alive) return c;
|
|
802
|
-
}
|
|
803
|
-
return null;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
export async function allocatePort(projectPath, probe = isMetroRunning) {
|
|
807
|
-
const reclaim = await findReclaimablePort(projectPath, probe);
|
|
808
|
-
if (reclaim) {
|
|
809
|
-
removeProject(reclaim.ownerPath);
|
|
810
|
-
return reclaim.port;
|
|
811
|
-
}
|
|
812
|
-
return computeNextPort();
|
|
813
|
-
}
|
|
814
|
-
```
|
|
815
|
-
|
|
816
|
-
- [ ] **Step 4: Run tests to verify they pass**
|
|
817
|
-
|
|
818
|
-
Run: `npm test -- test/ports.test.js`
|
|
819
|
-
Expected: PASS (7 tests).
|
|
820
|
-
|
|
821
|
-
- [ ] **Step 5: Commit**
|
|
822
|
-
|
|
823
|
-
```bash
|
|
824
|
-
git add src/ports.js test/ports.test.js
|
|
825
|
-
git commit -m "feat: port allocation with reclamation of dead Metro ports"
|
|
826
|
-
```
|
|
827
|
-
|
|
828
|
-
---
|
|
829
|
-
|
|
830
|
-
## Task 6: iOS sim listing + selection logic
|
|
831
|
-
|
|
832
|
-
The selection algorithm is pure given simctl output and the claimed-set. Implement and test it without invoking real simctl.
|
|
833
|
-
|
|
834
|
-
**Files:**
|
|
835
|
-
- Create: `src/sim/ios.js`
|
|
836
|
-
- Create: `test/sim-ios.test.js`
|
|
837
|
-
|
|
838
|
-
- [ ] **Step 1: Write failing tests**
|
|
839
|
-
|
|
840
|
-
```javascript
|
|
841
|
-
// test/sim-ios.test.js
|
|
842
|
-
import { test, beforeEach, afterEach } from 'node:test';
|
|
843
|
-
import assert from 'node:assert/strict';
|
|
844
|
-
import { mkdtempSync, rmSync } from 'fs';
|
|
845
|
-
import { tmpdir } from 'os';
|
|
846
|
-
import { join } from 'path';
|
|
847
|
-
import { setExecutor, resetExecutor } from '../src/exec.js';
|
|
848
|
-
import { parseSimctlList, selectIosDevice, listAllIosSims, listBootedIosSims } from '../src/sim/ios.js';
|
|
849
|
-
|
|
850
|
-
let tmpHome;
|
|
851
|
-
|
|
852
|
-
beforeEach(() => {
|
|
853
|
-
tmpHome = mkdtempSync(join(tmpdir(), 'rn-iso-test-'));
|
|
854
|
-
process.env.RN_ISO_HOME = tmpHome;
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
afterEach(() => {
|
|
858
|
-
rmSync(tmpHome, { recursive: true, force: true });
|
|
859
|
-
delete process.env.RN_ISO_HOME;
|
|
860
|
-
resetExecutor();
|
|
861
|
-
});
|
|
862
|
-
|
|
863
|
-
const SIMCTL_OUTPUT = JSON.stringify({
|
|
864
|
-
devices: {
|
|
865
|
-
'com.apple.CoreSimulator.SimRuntime.iOS-17-2': [
|
|
866
|
-
{ udid: 'UDID-A', name: 'iPhone 15', state: 'Booted', isAvailable: true },
|
|
867
|
-
{ udid: 'UDID-B', name: 'iPhone 15 Pro', state: 'Shutdown', isAvailable: true },
|
|
868
|
-
{ udid: 'UDID-C', name: 'iPhone 14', state: 'Booted', isAvailable: true },
|
|
869
|
-
],
|
|
870
|
-
'com.apple.CoreSimulator.SimRuntime.iOS-16-0': [
|
|
871
|
-
{ udid: 'UDID-OLD', name: 'iPhone 13', state: 'Shutdown', isAvailable: false },
|
|
872
|
-
],
|
|
873
|
-
},
|
|
874
|
-
});
|
|
875
|
-
|
|
876
|
-
test('parseSimctlList flattens devices and filters unavailable', () => {
|
|
877
|
-
const sims = parseSimctlList(SIMCTL_OUTPUT);
|
|
878
|
-
assert.equal(sims.length, 3);
|
|
879
|
-
assert.deepEqual(sims.map(s => s.udid).sort(), ['UDID-A', 'UDID-B', 'UDID-C']);
|
|
880
|
-
});
|
|
881
|
-
|
|
882
|
-
test('parseSimctlList includes runtime in each entry', () => {
|
|
883
|
-
const sims = parseSimctlList(SIMCTL_OUTPUT);
|
|
884
|
-
const a = sims.find(s => s.udid === 'UDID-A');
|
|
885
|
-
assert.equal(a.runtime, 'com.apple.CoreSimulator.SimRuntime.iOS-17-2');
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
test('listAllIosSims uses simctl via executor', () => {
|
|
889
|
-
setExecutor({
|
|
890
|
-
run: (cmd) => {
|
|
891
|
-
assert.match(cmd, /xcrun simctl list devices --json/);
|
|
892
|
-
return SIMCTL_OUTPUT;
|
|
893
|
-
},
|
|
894
|
-
runQuiet: () => null,
|
|
895
|
-
spawn: () => null,
|
|
896
|
-
});
|
|
897
|
-
const sims = listAllIosSims();
|
|
898
|
-
assert.equal(sims.length, 3);
|
|
899
|
-
});
|
|
900
|
-
|
|
901
|
-
test('listBootedIosSims filters by state', () => {
|
|
902
|
-
setExecutor({
|
|
903
|
-
run: () => SIMCTL_OUTPUT,
|
|
904
|
-
runQuiet: () => null,
|
|
905
|
-
spawn: () => null,
|
|
906
|
-
});
|
|
907
|
-
const booted = listBootedIosSims();
|
|
908
|
-
assert.deepEqual(booted.map(s => s.udid).sort(), ['UDID-A', 'UDID-C']);
|
|
909
|
-
});
|
|
910
|
-
|
|
911
|
-
test('selectIosDevice prefers existing assignment when sim still exists', () => {
|
|
912
|
-
setExecutor({ run: () => SIMCTL_OUTPUT, runQuiet: () => null, spawn: () => null });
|
|
913
|
-
const result = selectIosDevice({
|
|
914
|
-
existingUdid: 'UDID-B',
|
|
915
|
-
claimedUdids: [],
|
|
916
|
-
});
|
|
917
|
-
assert.deepEqual(result, { kind: 'reuse', udid: 'UDID-B', state: 'Shutdown' });
|
|
918
|
-
});
|
|
919
|
-
|
|
920
|
-
test('selectIosDevice ignores existing assignment when sim no longer exists', () => {
|
|
921
|
-
setExecutor({ run: () => SIMCTL_OUTPUT, runQuiet: () => null, spawn: () => null });
|
|
922
|
-
const result = selectIosDevice({
|
|
923
|
-
existingUdid: 'GHOST-UDID',
|
|
924
|
-
claimedUdids: [],
|
|
925
|
-
});
|
|
926
|
-
assert.equal(result.kind, 'allocate');
|
|
927
|
-
});
|
|
928
|
-
|
|
929
|
-
test('selectIosDevice allocates first booted-and-unclaimed sim', () => {
|
|
930
|
-
setExecutor({ run: () => SIMCTL_OUTPUT, runQuiet: () => null, spawn: () => null });
|
|
931
|
-
const result = selectIosDevice({
|
|
932
|
-
existingUdid: null,
|
|
933
|
-
claimedUdids: ['UDID-A'],
|
|
934
|
-
});
|
|
935
|
-
assert.deepEqual(result, { kind: 'allocate', udid: 'UDID-C', state: 'Booted' });
|
|
936
|
-
});
|
|
937
|
-
|
|
938
|
-
test('selectIosDevice returns needsBoot when nothing booted+unclaimed', () => {
|
|
939
|
-
setExecutor({ run: () => SIMCTL_OUTPUT, runQuiet: () => null, spawn: () => null });
|
|
940
|
-
const result = selectIosDevice({
|
|
941
|
-
existingUdid: null,
|
|
942
|
-
claimedUdids: ['UDID-A', 'UDID-C'],
|
|
943
|
-
});
|
|
944
|
-
assert.equal(result.kind, 'needsBoot');
|
|
945
|
-
});
|
|
946
|
-
```
|
|
947
|
-
|
|
948
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
949
|
-
|
|
950
|
-
Run: `npm test -- test/sim-ios.test.js`
|
|
951
|
-
Expected: FAIL — module not found.
|
|
952
|
-
|
|
953
|
-
- [ ] **Step 3: Implement src/sim/ios.js**
|
|
954
|
-
|
|
955
|
-
```javascript
|
|
956
|
-
// src/sim/ios.js
|
|
957
|
-
import { getExecutor } from '../exec.js';
|
|
958
|
-
|
|
959
|
-
export function parseSimctlList(jsonOutput) {
|
|
960
|
-
const data = JSON.parse(jsonOutput);
|
|
961
|
-
const sims = [];
|
|
962
|
-
for (const [runtime, devices] of Object.entries(data.devices || {})) {
|
|
963
|
-
for (const dev of devices) {
|
|
964
|
-
if (!dev.isAvailable) continue;
|
|
965
|
-
sims.push({
|
|
966
|
-
udid: dev.udid,
|
|
967
|
-
name: dev.name,
|
|
968
|
-
state: dev.state,
|
|
969
|
-
runtime,
|
|
970
|
-
});
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
return sims;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
export function listAllIosSims() {
|
|
977
|
-
const out = getExecutor().run('xcrun simctl list devices --json');
|
|
978
|
-
return parseSimctlList(out);
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
export function listBootedIosSims() {
|
|
982
|
-
return listAllIosSims().filter(s => s.state === 'Booted');
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
export function selectIosDevice({ existingUdid, claimedUdids }) {
|
|
986
|
-
const sims = listAllIosSims();
|
|
987
|
-
const claimed = new Set(claimedUdids);
|
|
988
|
-
|
|
989
|
-
if (existingUdid) {
|
|
990
|
-
const found = sims.find(s => s.udid === existingUdid);
|
|
991
|
-
if (found) {
|
|
992
|
-
return { kind: 'reuse', udid: found.udid, state: found.state };
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
const candidate = sims.find(s => s.state === 'Booted' && !claimed.has(s.udid));
|
|
997
|
-
if (candidate) {
|
|
998
|
-
return { kind: 'allocate', udid: candidate.udid, state: candidate.state };
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
return { kind: 'needsBoot' };
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
export function bootIosSim(udid) {
|
|
1005
|
-
const exec = getExecutor();
|
|
1006
|
-
// simctl errors if already booted; use runQuiet to swallow.
|
|
1007
|
-
exec.runQuiet(`xcrun simctl boot ${udid}`);
|
|
1008
|
-
exec.runQuiet('open -a Simulator');
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
export function shutdownIosSim(udid) {
|
|
1012
|
-
getExecutor().runQuiet(`xcrun simctl shutdown ${udid}`);
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
export function listIosDeviceTypes() {
|
|
1016
|
-
const exec = getExecutor();
|
|
1017
|
-
const out = exec.run('xcrun simctl list devicetypes --json');
|
|
1018
|
-
const data = JSON.parse(out);
|
|
1019
|
-
return (data.devicetypes || []).map(dt => ({
|
|
1020
|
-
identifier: dt.identifier,
|
|
1021
|
-
name: dt.name,
|
|
1022
|
-
}));
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
export function createIosSim(deviceTypeId, runtimeId) {
|
|
1026
|
-
const out = getExecutor().run(`xcrun simctl create "rn-iso" "${deviceTypeId}" "${runtimeId}"`);
|
|
1027
|
-
return out.trim();
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
export function listIosRuntimes() {
|
|
1031
|
-
const out = getExecutor().run('xcrun simctl list runtimes --json');
|
|
1032
|
-
const data = JSON.parse(out);
|
|
1033
|
-
return (data.runtimes || [])
|
|
1034
|
-
.filter(r => r.isAvailable && r.platform === 'iOS')
|
|
1035
|
-
.map(r => ({ identifier: r.identifier, name: r.name, version: r.version }));
|
|
1036
|
-
}
|
|
1037
|
-
```
|
|
1038
|
-
|
|
1039
|
-
- [ ] **Step 4: Run tests to verify they pass**
|
|
1040
|
-
|
|
1041
|
-
Run: `npm test -- test/sim-ios.test.js`
|
|
1042
|
-
Expected: PASS (8 tests).
|
|
1043
|
-
|
|
1044
|
-
- [ ] **Step 5: Commit**
|
|
1045
|
-
|
|
1046
|
-
```bash
|
|
1047
|
-
git add src/sim/ios.js test/sim-ios.test.js
|
|
1048
|
-
git commit -m "feat: iOS simctl wrappers and device selection algorithm"
|
|
1049
|
-
```
|
|
1050
|
-
|
|
1051
|
-
---
|
|
1052
|
-
|
|
1053
|
-
## Task 7: Android emulator listing + selection logic
|
|
1054
|
-
|
|
1055
|
-
**Files:**
|
|
1056
|
-
- Create: `src/sim/android.js`
|
|
1057
|
-
- Create: `test/sim-android.test.js`
|
|
1058
|
-
|
|
1059
|
-
- [ ] **Step 1: Write failing tests**
|
|
1060
|
-
|
|
1061
|
-
```javascript
|
|
1062
|
-
// test/sim-android.test.js
|
|
1063
|
-
import { test, beforeEach, afterEach } from 'node:test';
|
|
1064
|
-
import assert from 'node:assert/strict';
|
|
1065
|
-
import { mkdtempSync, rmSync } from 'fs';
|
|
1066
|
-
import { tmpdir } from 'os';
|
|
1067
|
-
import { join } from 'path';
|
|
1068
|
-
import { setExecutor, resetExecutor } from '../src/exec.js';
|
|
1069
|
-
import {
|
|
1070
|
-
parseAvdList,
|
|
1071
|
-
parseAdbDevices,
|
|
1072
|
-
selectAndroidDevice,
|
|
1073
|
-
nextConsolePort,
|
|
1074
|
-
} from '../src/sim/android.js';
|
|
1075
|
-
|
|
1076
|
-
let tmpHome;
|
|
1077
|
-
|
|
1078
|
-
beforeEach(() => {
|
|
1079
|
-
tmpHome = mkdtempSync(join(tmpdir(), 'rn-iso-test-'));
|
|
1080
|
-
process.env.RN_ISO_HOME = tmpHome;
|
|
1081
|
-
});
|
|
1082
|
-
|
|
1083
|
-
afterEach(() => {
|
|
1084
|
-
rmSync(tmpHome, { recursive: true, force: true });
|
|
1085
|
-
delete process.env.RN_ISO_HOME;
|
|
1086
|
-
resetExecutor();
|
|
1087
|
-
});
|
|
1088
|
-
|
|
1089
|
-
test('parseAvdList strips header and blanks', () => {
|
|
1090
|
-
const out = `INFO | Storing AVDs in...\nPixel_6_API_34\nPixel_7_API_33\n`;
|
|
1091
|
-
const avds = parseAvdList(out);
|
|
1092
|
-
assert.deepEqual(avds, ['Pixel_6_API_34', 'Pixel_7_API_33']);
|
|
1093
|
-
});
|
|
1094
|
-
|
|
1095
|
-
test('parseAdbDevices extracts running emulator console ports', () => {
|
|
1096
|
-
const out = `List of devices attached\nemulator-5554\tdevice\nemulator-5556\tdevice\n0123456789ABCDEF\tdevice\n`;
|
|
1097
|
-
const result = parseAdbDevices(out);
|
|
1098
|
-
assert.deepEqual(result.emulators.sort((a, b) => a.consolePort - b.consolePort), [
|
|
1099
|
-
{ serial: 'emulator-5554', consolePort: 5554 },
|
|
1100
|
-
{ serial: 'emulator-5556', consolePort: 5556 },
|
|
1101
|
-
]);
|
|
1102
|
-
});
|
|
1103
|
-
|
|
1104
|
-
test('parseAdbDevices ignores offline emulators', () => {
|
|
1105
|
-
const out = `List of devices attached\nemulator-5554\toffline\nemulator-5556\tdevice\n`;
|
|
1106
|
-
const result = parseAdbDevices(out);
|
|
1107
|
-
assert.deepEqual(result.emulators, [{ serial: 'emulator-5556', consolePort: 5556 }]);
|
|
1108
|
-
});
|
|
1109
|
-
|
|
1110
|
-
test('nextConsolePort returns 5554 when none claimed', () => {
|
|
1111
|
-
assert.equal(nextConsolePort([]), 5554);
|
|
1112
|
-
});
|
|
1113
|
-
|
|
1114
|
-
test('nextConsolePort returns next even port above max claimed', () => {
|
|
1115
|
-
assert.equal(nextConsolePort([5554, 5556]), 5558);
|
|
1116
|
-
});
|
|
1117
|
-
|
|
1118
|
-
test('selectAndroidDevice prefers existing assignment when AVD still exists', () => {
|
|
1119
|
-
setExecutor({
|
|
1120
|
-
run: (cmd) => {
|
|
1121
|
-
if (cmd.includes('list-avds')) return 'Pixel_6_API_34\n';
|
|
1122
|
-
if (cmd.includes('adb devices')) return 'List of devices attached\n';
|
|
1123
|
-
throw new Error('unexpected: ' + cmd);
|
|
1124
|
-
},
|
|
1125
|
-
runQuiet: () => null,
|
|
1126
|
-
spawn: () => null,
|
|
1127
|
-
});
|
|
1128
|
-
const result = selectAndroidDevice({
|
|
1129
|
-
existingAvd: 'Pixel_6_API_34',
|
|
1130
|
-
existingConsolePort: 5554,
|
|
1131
|
-
claimedAvds: [],
|
|
1132
|
-
claimedConsolePorts: [],
|
|
1133
|
-
});
|
|
1134
|
-
assert.deepEqual(result, {
|
|
1135
|
-
kind: 'reuse',
|
|
1136
|
-
avdName: 'Pixel_6_API_34',
|
|
1137
|
-
consolePort: 5554,
|
|
1138
|
-
isRunning: false,
|
|
1139
|
-
});
|
|
1140
|
-
});
|
|
1141
|
-
|
|
1142
|
-
test('selectAndroidDevice marks running when serial present in adb devices', () => {
|
|
1143
|
-
setExecutor({
|
|
1144
|
-
run: (cmd) => {
|
|
1145
|
-
if (cmd.includes('list-avds')) return 'Pixel_6_API_34\n';
|
|
1146
|
-
if (cmd.includes('adb devices')) return 'List of devices attached\nemulator-5554\tdevice\n';
|
|
1147
|
-
throw new Error('unexpected');
|
|
1148
|
-
},
|
|
1149
|
-
runQuiet: () => null,
|
|
1150
|
-
spawn: () => null,
|
|
1151
|
-
});
|
|
1152
|
-
const result = selectAndroidDevice({
|
|
1153
|
-
existingAvd: 'Pixel_6_API_34',
|
|
1154
|
-
existingConsolePort: 5554,
|
|
1155
|
-
claimedAvds: [],
|
|
1156
|
-
claimedConsolePorts: [],
|
|
1157
|
-
});
|
|
1158
|
-
assert.equal(result.isRunning, true);
|
|
1159
|
-
});
|
|
1160
|
-
|
|
1161
|
-
test('selectAndroidDevice allocates first unclaimed AVD with next console port', () => {
|
|
1162
|
-
setExecutor({
|
|
1163
|
-
run: (cmd) => {
|
|
1164
|
-
if (cmd.includes('list-avds')) return 'Pixel_6_API_34\nPixel_7_API_33\n';
|
|
1165
|
-
if (cmd.includes('adb devices')) return 'List of devices attached\n';
|
|
1166
|
-
throw new Error('unexpected');
|
|
1167
|
-
},
|
|
1168
|
-
runQuiet: () => null,
|
|
1169
|
-
spawn: () => null,
|
|
1170
|
-
});
|
|
1171
|
-
const result = selectAndroidDevice({
|
|
1172
|
-
existingAvd: null,
|
|
1173
|
-
existingConsolePort: null,
|
|
1174
|
-
claimedAvds: ['Pixel_6_API_34'],
|
|
1175
|
-
claimedConsolePorts: [5554],
|
|
1176
|
-
});
|
|
1177
|
-
assert.deepEqual(result, {
|
|
1178
|
-
kind: 'allocate',
|
|
1179
|
-
avdName: 'Pixel_7_API_33',
|
|
1180
|
-
consolePort: 5556,
|
|
1181
|
-
isRunning: false,
|
|
1182
|
-
});
|
|
1183
|
-
});
|
|
1184
|
-
|
|
1185
|
-
test('selectAndroidDevice returns noAvd when no AVDs exist', () => {
|
|
1186
|
-
setExecutor({
|
|
1187
|
-
run: (cmd) => {
|
|
1188
|
-
if (cmd.includes('list-avds')) return '';
|
|
1189
|
-
if (cmd.includes('adb devices')) return 'List of devices attached\n';
|
|
1190
|
-
throw new Error('unexpected');
|
|
1191
|
-
},
|
|
1192
|
-
runQuiet: () => null,
|
|
1193
|
-
spawn: () => null,
|
|
1194
|
-
});
|
|
1195
|
-
const result = selectAndroidDevice({
|
|
1196
|
-
existingAvd: null,
|
|
1197
|
-
existingConsolePort: null,
|
|
1198
|
-
claimedAvds: [],
|
|
1199
|
-
claimedConsolePorts: [],
|
|
1200
|
-
});
|
|
1201
|
-
assert.equal(result.kind, 'noAvd');
|
|
1202
|
-
});
|
|
1203
|
-
```
|
|
1204
|
-
|
|
1205
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
1206
|
-
|
|
1207
|
-
Run: `npm test -- test/sim-android.test.js`
|
|
1208
|
-
Expected: FAIL — module not found.
|
|
1209
|
-
|
|
1210
|
-
- [ ] **Step 3: Implement src/sim/android.js**
|
|
1211
|
-
|
|
1212
|
-
```javascript
|
|
1213
|
-
// src/sim/android.js
|
|
1214
|
-
import { getExecutor } from '../exec.js';
|
|
1215
|
-
|
|
1216
|
-
export function parseAvdList(text) {
|
|
1217
|
-
return text
|
|
1218
|
-
.split('\n')
|
|
1219
|
-
.map(l => l.trim())
|
|
1220
|
-
.filter(l => l && !l.startsWith('INFO') && !l.startsWith('WARNING'));
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
export function parseAdbDevices(text) {
|
|
1224
|
-
const lines = text.split('\n').slice(1); // skip "List of devices attached"
|
|
1225
|
-
const emulators = [];
|
|
1226
|
-
for (const line of lines) {
|
|
1227
|
-
const trimmed = line.trim();
|
|
1228
|
-
if (!trimmed) continue;
|
|
1229
|
-
const [serial, status] = trimmed.split(/\s+/);
|
|
1230
|
-
if (status !== 'device') continue;
|
|
1231
|
-
const m = serial.match(/^emulator-(\d+)$/);
|
|
1232
|
-
if (m) emulators.push({ serial, consolePort: parseInt(m[1], 10) });
|
|
1233
|
-
}
|
|
1234
|
-
return { emulators };
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
export function listAvds() {
|
|
1238
|
-
return parseAvdList(getExecutor().run('emulator -list-avds'));
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
export function listAdbDevices() {
|
|
1242
|
-
return parseAdbDevices(getExecutor().run('adb devices'));
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
export function nextConsolePort(claimedPorts) {
|
|
1246
|
-
if (claimedPorts.length === 0) return 5554;
|
|
1247
|
-
const max = Math.max(...claimedPorts);
|
|
1248
|
-
return max + 2; // emulator console ports are even
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedAvds, claimedConsolePorts }) {
|
|
1252
|
-
const avds = listAvds();
|
|
1253
|
-
const adbDevices = listAdbDevices();
|
|
1254
|
-
const runningPorts = new Set(adbDevices.emulators.map(e => e.consolePort));
|
|
1255
|
-
|
|
1256
|
-
if (existingAvd && avds.includes(existingAvd)) {
|
|
1257
|
-
const port = existingConsolePort ?? nextConsolePort(claimedConsolePorts);
|
|
1258
|
-
return {
|
|
1259
|
-
kind: 'reuse',
|
|
1260
|
-
avdName: existingAvd,
|
|
1261
|
-
consolePort: port,
|
|
1262
|
-
isRunning: runningPorts.has(port),
|
|
1263
|
-
};
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
if (avds.length === 0) {
|
|
1267
|
-
return { kind: 'noAvd' };
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
const claimedAvdSet = new Set(claimedAvds);
|
|
1271
|
-
const candidate = avds.find(a => !claimedAvdSet.has(a));
|
|
1272
|
-
if (!candidate) {
|
|
1273
|
-
return { kind: 'noAvd' };
|
|
1274
|
-
}
|
|
1275
|
-
const consolePort = nextConsolePort(claimedConsolePorts);
|
|
1276
|
-
return {
|
|
1277
|
-
kind: 'allocate',
|
|
1278
|
-
avdName: candidate,
|
|
1279
|
-
consolePort,
|
|
1280
|
-
isRunning: runningPorts.has(consolePort),
|
|
1281
|
-
};
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
export function bootAndroidEmulator(avdName, consolePort) {
|
|
1285
|
-
const exec = getExecutor();
|
|
1286
|
-
exec.spawn('emulator', ['-avd', avdName, '-port', String(consolePort)], {
|
|
1287
|
-
detached: true,
|
|
1288
|
-
stdio: 'ignore',
|
|
1289
|
-
}).unref();
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
export async function waitForBoot(serial, timeoutMs = 60000) {
|
|
1293
|
-
const exec = getExecutor();
|
|
1294
|
-
const start = Date.now();
|
|
1295
|
-
while (Date.now() - start < timeoutMs) {
|
|
1296
|
-
const out = exec.runQuiet(`adb -s ${serial} shell getprop sys.boot_completed`);
|
|
1297
|
-
if (out && out.trim() === '1') return true;
|
|
1298
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
1299
|
-
}
|
|
1300
|
-
return false;
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
export function shutdownAndroidEmulator(serial) {
|
|
1304
|
-
getExecutor().runQuiet(`adb -s ${serial} emu kill`);
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
export function adbReverse(serial, port) {
|
|
1308
|
-
getExecutor().run(`adb -s ${serial} reverse tcp:${port} tcp:${port}`);
|
|
1309
|
-
}
|
|
1310
|
-
```
|
|
1311
|
-
|
|
1312
|
-
- [ ] **Step 4: Run tests to verify they pass**
|
|
1313
|
-
|
|
1314
|
-
Run: `npm test -- test/sim-android.test.js`
|
|
1315
|
-
Expected: PASS (9 tests).
|
|
1316
|
-
|
|
1317
|
-
- [ ] **Step 5: Commit**
|
|
1318
|
-
|
|
1319
|
-
```bash
|
|
1320
|
-
git add src/sim/android.js test/sim-android.test.js
|
|
1321
|
-
git commit -m "feat: Android emulator listing and device selection"
|
|
1322
|
-
```
|
|
1323
|
-
|
|
1324
|
-
---
|
|
1325
|
-
|
|
1326
|
-
## Task 8: Runner module (Expo vs bare dispatch)
|
|
1327
|
-
|
|
1328
|
-
**Files:**
|
|
1329
|
-
- Create: `src/runner.js`
|
|
1330
|
-
- Create: `test/runner.test.js`
|
|
1331
|
-
|
|
1332
|
-
- [ ] **Step 1: Write failing tests**
|
|
1333
|
-
|
|
1334
|
-
```javascript
|
|
1335
|
-
// test/runner.test.js
|
|
1336
|
-
import { test, beforeEach, afterEach } from 'node:test';
|
|
1337
|
-
import assert from 'node:assert/strict';
|
|
1338
|
-
import { setExecutor, resetExecutor } from '../src/exec.js';
|
|
1339
|
-
import { buildIosCommand, buildAndroidCommand, buildMetroCommand, resolveSimNameByUdid } from '../src/runner.js';
|
|
1340
|
-
|
|
1341
|
-
afterEach(() => resetExecutor());
|
|
1342
|
-
|
|
1343
|
-
test('buildIosCommand uses expo run:ios when isExpo', () => {
|
|
1344
|
-
const cmd = buildIosCommand({ isExpo: true, udid: 'UDID-1', port: 8083, simName: 'iPhone 15' });
|
|
1345
|
-
assert.equal(cmd, 'npx expo run:ios --device UDID-1 --port 8083');
|
|
1346
|
-
});
|
|
1347
|
-
|
|
1348
|
-
test('buildIosCommand uses react-native run-ios when bare and resolves sim name', () => {
|
|
1349
|
-
const cmd = buildIosCommand({ isExpo: false, udid: 'UDID-1', port: 8083, simName: 'iPhone 15' });
|
|
1350
|
-
assert.equal(cmd, 'npx react-native run-ios --simulator "iPhone 15" --port 8083');
|
|
1351
|
-
});
|
|
1352
|
-
|
|
1353
|
-
test('buildAndroidCommand for expo uses --device serial', () => {
|
|
1354
|
-
const cmd = buildAndroidCommand({ isExpo: true, serial: 'emulator-5554', port: 8083 });
|
|
1355
|
-
assert.equal(cmd, 'npx expo run:android --device emulator-5554 --port 8083');
|
|
1356
|
-
});
|
|
1357
|
-
|
|
1358
|
-
test('buildAndroidCommand for bare uses --deviceId and RCT_METRO_PORT env prefix', () => {
|
|
1359
|
-
const cmd = buildAndroidCommand({ isExpo: false, serial: 'emulator-5554', port: 8083 });
|
|
1360
|
-
assert.equal(cmd, 'RCT_METRO_PORT=8083 npx react-native run-android --deviceId emulator-5554');
|
|
1361
|
-
});
|
|
1362
|
-
|
|
1363
|
-
test('buildMetroCommand picks expo or react-native', () => {
|
|
1364
|
-
assert.equal(buildMetroCommand({ isExpo: true, port: 8083 }), 'npx expo start --port 8083');
|
|
1365
|
-
assert.equal(buildMetroCommand({ isExpo: false, port: 8083 }), 'npx react-native start --port 8083');
|
|
1366
|
-
});
|
|
1367
|
-
|
|
1368
|
-
test('resolveSimNameByUdid returns name from simctl JSON', () => {
|
|
1369
|
-
setExecutor({
|
|
1370
|
-
run: () => JSON.stringify({
|
|
1371
|
-
devices: {
|
|
1372
|
-
'iOS-17': [{ udid: 'UDID-1', name: 'iPhone 15', state: 'Booted', isAvailable: true }],
|
|
1373
|
-
},
|
|
1374
|
-
}),
|
|
1375
|
-
runQuiet: () => null,
|
|
1376
|
-
spawn: () => null,
|
|
1377
|
-
});
|
|
1378
|
-
assert.equal(resolveSimNameByUdid('UDID-1'), 'iPhone 15');
|
|
1379
|
-
});
|
|
1380
|
-
|
|
1381
|
-
test('resolveSimNameByUdid throws when ambiguous', () => {
|
|
1382
|
-
setExecutor({
|
|
1383
|
-
run: () => JSON.stringify({
|
|
1384
|
-
devices: {
|
|
1385
|
-
'iOS-17': [
|
|
1386
|
-
{ udid: 'UDID-1', name: 'iPhone 15', state: 'Booted', isAvailable: true },
|
|
1387
|
-
{ udid: 'UDID-2', name: 'iPhone 15', state: 'Shutdown', isAvailable: true },
|
|
1388
|
-
],
|
|
1389
|
-
},
|
|
1390
|
-
}),
|
|
1391
|
-
runQuiet: () => null,
|
|
1392
|
-
spawn: () => null,
|
|
1393
|
-
});
|
|
1394
|
-
assert.throws(
|
|
1395
|
-
() => resolveSimNameByUdid('UDID-1'),
|
|
1396
|
-
/ambiguous/i
|
|
1397
|
-
);
|
|
1398
|
-
});
|
|
1399
|
-
```
|
|
1400
|
-
|
|
1401
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
1402
|
-
|
|
1403
|
-
Run: `npm test -- test/runner.test.js`
|
|
1404
|
-
Expected: FAIL — module not found.
|
|
1405
|
-
|
|
1406
|
-
- [ ] **Step 3: Implement src/runner.js**
|
|
1407
|
-
|
|
1408
|
-
```javascript
|
|
1409
|
-
// src/runner.js
|
|
1410
|
-
import { listAllIosSims } from './sim/ios.js';
|
|
1411
|
-
|
|
1412
|
-
export function buildIosCommand({ isExpo, udid, port, simName }) {
|
|
1413
|
-
if (isExpo) {
|
|
1414
|
-
return `npx expo run:ios --device ${udid} --port ${port}`;
|
|
1415
|
-
}
|
|
1416
|
-
return `npx react-native run-ios --simulator "${simName}" --port ${port}`;
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
export function buildAndroidCommand({ isExpo, serial, port }) {
|
|
1420
|
-
if (isExpo) {
|
|
1421
|
-
return `npx expo run:android --device ${serial} --port ${port}`;
|
|
1422
|
-
}
|
|
1423
|
-
return `RCT_METRO_PORT=${port} npx react-native run-android --deviceId ${serial}`;
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
export function buildMetroCommand({ isExpo, port }) {
|
|
1427
|
-
return isExpo
|
|
1428
|
-
? `npx expo start --port ${port}`
|
|
1429
|
-
: `npx react-native start --port ${port}`;
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
export function resolveSimNameByUdid(udid) {
|
|
1433
|
-
const sims = listAllIosSims();
|
|
1434
|
-
const target = sims.find(s => s.udid === udid);
|
|
1435
|
-
if (!target) throw new Error(`Simulator UDID not found: ${udid}`);
|
|
1436
|
-
const sameName = sims.filter(s => s.name === target.name);
|
|
1437
|
-
if (sameName.length > 1) {
|
|
1438
|
-
throw new Error(
|
|
1439
|
-
`Multiple simulators named "${target.name}" — bare RN takes a name, not UDID. ` +
|
|
1440
|
-
`Rename one in the Simulator app.`
|
|
1441
|
-
);
|
|
1442
|
-
}
|
|
1443
|
-
return target.name;
|
|
1444
|
-
}
|
|
1445
|
-
```
|
|
1446
|
-
|
|
1447
|
-
- [ ] **Step 4: Run tests to verify they pass**
|
|
1448
|
-
|
|
1449
|
-
Run: `npm test -- test/runner.test.js`
|
|
1450
|
-
Expected: PASS (7 tests).
|
|
1451
|
-
|
|
1452
|
-
- [ ] **Step 5: Commit**
|
|
1453
|
-
|
|
1454
|
-
```bash
|
|
1455
|
-
git add src/runner.js test/runner.test.js
|
|
1456
|
-
git commit -m "feat: Expo vs bare dispatch for run/start commands"
|
|
1457
|
-
```
|
|
1458
|
-
|
|
1459
|
-
---
|
|
1460
|
-
|
|
1461
|
-
## Task 9: Metro process management
|
|
1462
|
-
|
|
1463
|
-
**Files:**
|
|
1464
|
-
- Create: `src/metro.js`
|
|
1465
|
-
- Create: `test/metro.test.js`
|
|
1466
|
-
|
|
1467
|
-
- [ ] **Step 1: Write failing tests**
|
|
1468
|
-
|
|
1469
|
-
```javascript
|
|
1470
|
-
// test/metro.test.js
|
|
1471
|
-
import { test, afterEach } from 'node:test';
|
|
1472
|
-
import assert from 'node:assert/strict';
|
|
1473
|
-
import { setExecutor, resetExecutor } from '../src/exec.js';
|
|
1474
|
-
import { logFileFor, projectHash, buildMetroSpawnArgs } from '../src/metro.js';
|
|
1475
|
-
|
|
1476
|
-
afterEach(() => resetExecutor());
|
|
1477
|
-
|
|
1478
|
-
test('projectHash is deterministic and short', () => {
|
|
1479
|
-
const a = projectHash('/foo/bar');
|
|
1480
|
-
const b = projectHash('/foo/bar');
|
|
1481
|
-
const c = projectHash('/foo/baz');
|
|
1482
|
-
assert.equal(a, b);
|
|
1483
|
-
assert.notEqual(a, c);
|
|
1484
|
-
assert.equal(a.length, 12);
|
|
1485
|
-
});
|
|
1486
|
-
|
|
1487
|
-
test('logFileFor uses RN_ISO_HOME and project hash', () => {
|
|
1488
|
-
process.env.RN_ISO_HOME = '/tmp/test-rn-iso';
|
|
1489
|
-
const path = logFileFor('/some/project');
|
|
1490
|
-
assert.match(path, /^\/tmp\/test-rn-iso\/logs\/[0-9a-f]{12}\.log$/);
|
|
1491
|
-
delete process.env.RN_ISO_HOME;
|
|
1492
|
-
});
|
|
1493
|
-
|
|
1494
|
-
test('buildMetroSpawnArgs returns correct argv for expo', () => {
|
|
1495
|
-
const { cmd, args } = buildMetroSpawnArgs({ isExpo: true, port: 8083 });
|
|
1496
|
-
assert.equal(cmd, 'npx');
|
|
1497
|
-
assert.deepEqual(args, ['expo', 'start', '--port', '8083']);
|
|
1498
|
-
});
|
|
1499
|
-
|
|
1500
|
-
test('buildMetroSpawnArgs returns correct argv for bare', () => {
|
|
1501
|
-
const { cmd, args } = buildMetroSpawnArgs({ isExpo: false, port: 8083 });
|
|
1502
|
-
assert.equal(cmd, 'npx');
|
|
1503
|
-
assert.deepEqual(args, ['react-native', 'start', '--port', '8083']);
|
|
1504
|
-
});
|
|
1505
|
-
```
|
|
1506
|
-
|
|
1507
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
1508
|
-
|
|
1509
|
-
Run: `npm test -- test/metro.test.js`
|
|
1510
|
-
Expected: FAIL — module not found.
|
|
1511
|
-
|
|
1512
|
-
- [ ] **Step 3: Implement src/metro.js**
|
|
1513
|
-
|
|
1514
|
-
```javascript
|
|
1515
|
-
// src/metro.js
|
|
1516
|
-
import { createHash } from 'crypto';
|
|
1517
|
-
import { mkdirSync, existsSync, openSync, statSync } from 'fs';
|
|
1518
|
-
import { join } from 'path';
|
|
1519
|
-
import { getExecutor } from './exec.js';
|
|
1520
|
-
import { getConfigDir } from './config.js';
|
|
1521
|
-
import { isMetroRunning } from './ports.js';
|
|
1522
|
-
|
|
1523
|
-
export function projectHash(projectPath) {
|
|
1524
|
-
return createHash('sha256').update(projectPath).digest('hex').slice(0, 12);
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
export function logFileFor(projectPath) {
|
|
1528
|
-
const dir = join(getConfigDir(), 'logs');
|
|
1529
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1530
|
-
return join(dir, `${projectHash(projectPath)}.log`);
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
export function buildMetroSpawnArgs({ isExpo, port }) {
|
|
1534
|
-
return {
|
|
1535
|
-
cmd: 'npx',
|
|
1536
|
-
args: isExpo
|
|
1537
|
-
? ['expo', 'start', '--port', String(port)]
|
|
1538
|
-
: ['react-native', 'start', '--port', String(port)],
|
|
1539
|
-
};
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
export async function ensureMetro({ projectPath, isExpo, port, detach = true }) {
|
|
1543
|
-
if (await isMetroRunning(port)) return { alreadyRunning: true, pid: null };
|
|
1544
|
-
|
|
1545
|
-
const log = logFileFor(projectPath);
|
|
1546
|
-
const fd = openSync(log, 'a');
|
|
1547
|
-
|
|
1548
|
-
const { cmd, args } = buildMetroSpawnArgs({ isExpo, port });
|
|
1549
|
-
const exec = getExecutor();
|
|
1550
|
-
const child = exec.spawn(cmd, args, {
|
|
1551
|
-
cwd: projectPath,
|
|
1552
|
-
detached: detach,
|
|
1553
|
-
stdio: ['ignore', fd, fd],
|
|
1554
|
-
env: { ...process.env, RCT_METRO_PORT: String(port) },
|
|
1555
|
-
});
|
|
1556
|
-
if (detach) child.unref();
|
|
1557
|
-
return { alreadyRunning: false, pid: child.pid };
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
export function killMetroByPid(pid) {
|
|
1561
|
-
if (!pid) return false;
|
|
1562
|
-
try {
|
|
1563
|
-
process.kill(pid, 'SIGTERM');
|
|
1564
|
-
return true;
|
|
1565
|
-
} catch {
|
|
1566
|
-
return false;
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
export function isPidAlive(pid) {
|
|
1571
|
-
if (!pid) return false;
|
|
1572
|
-
try {
|
|
1573
|
-
process.kill(pid, 0);
|
|
1574
|
-
return true;
|
|
1575
|
-
} catch {
|
|
1576
|
-
return false;
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
export function logFileExists(projectPath) {
|
|
1581
|
-
const path = logFileFor(projectPath);
|
|
1582
|
-
try {
|
|
1583
|
-
statSync(path);
|
|
1584
|
-
return path;
|
|
1585
|
-
} catch {
|
|
1586
|
-
return null;
|
|
1587
|
-
}
|
|
1588
|
-
}
|
|
1589
|
-
```
|
|
1590
|
-
|
|
1591
|
-
- [ ] **Step 4: Run tests to verify they pass**
|
|
1592
|
-
|
|
1593
|
-
Run: `npm test -- test/metro.test.js`
|
|
1594
|
-
Expected: PASS (4 tests).
|
|
1595
|
-
|
|
1596
|
-
- [ ] **Step 5: Commit**
|
|
1597
|
-
|
|
1598
|
-
```bash
|
|
1599
|
-
git add src/metro.js test/metro.test.js
|
|
1600
|
-
git commit -m "feat: Metro process management with detached spawn and log files"
|
|
1601
|
-
```
|
|
1602
|
-
|
|
1603
|
-
---
|
|
1604
|
-
|
|
1605
|
-
## Task 10: `rn-iso device` command
|
|
1606
|
-
|
|
1607
|
-
The simplest user-facing command — surface assignment lookup.
|
|
1608
|
-
|
|
1609
|
-
**Files:**
|
|
1610
|
-
- Create: `src/commands/device.js`
|
|
1611
|
-
- Modify: `bin/cli.js`
|
|
1612
|
-
|
|
1613
|
-
- [ ] **Step 1: Implement src/commands/device.js**
|
|
1614
|
-
|
|
1615
|
-
```javascript
|
|
1616
|
-
// src/commands/device.js
|
|
1617
|
-
import chalk from 'chalk';
|
|
1618
|
-
import { findProjectRoot } from '../project.js';
|
|
1619
|
-
import { getProject } from '../config.js';
|
|
1620
|
-
|
|
1621
|
-
export default function deviceCommand(program) {
|
|
1622
|
-
program
|
|
1623
|
-
.command('device')
|
|
1624
|
-
.description('Print the assigned device UDID/serial for the current project')
|
|
1625
|
-
.option('--platform <platform>', 'ios or android', 'ios')
|
|
1626
|
-
.option('--json', 'Emit JSON with full assignment info')
|
|
1627
|
-
.action((opts) => {
|
|
1628
|
-
const root = findProjectRoot(process.cwd());
|
|
1629
|
-
if (!root) {
|
|
1630
|
-
console.error(chalk.red('Not in a React Native project (no package.json found).'));
|
|
1631
|
-
process.exit(1);
|
|
1632
|
-
}
|
|
1633
|
-
const proj = getProject(root);
|
|
1634
|
-
if (!proj) {
|
|
1635
|
-
console.error(chalk.red(`No rn-iso assignment for project ${root}. Run \`rn-iso ${opts.platform}\` first.`));
|
|
1636
|
-
process.exit(1);
|
|
1637
|
-
}
|
|
1638
|
-
const platformEntry = proj.platforms?.[opts.platform];
|
|
1639
|
-
if (!platformEntry) {
|
|
1640
|
-
console.error(chalk.red(`No ${opts.platform} device assigned. Run \`rn-iso ${opts.platform}\` first.`));
|
|
1641
|
-
process.exit(1);
|
|
1642
|
-
}
|
|
1643
|
-
|
|
1644
|
-
if (opts.json) {
|
|
1645
|
-
const payload = opts.platform === 'ios'
|
|
1646
|
-
? { platform: 'ios', udid: platformEntry.deviceUdid, metroPort: proj.metroPort }
|
|
1647
|
-
: { platform: 'android', serial: `emulator-${platformEntry.consolePort}`, avdName: platformEntry.avdName, consolePort: platformEntry.consolePort, metroPort: proj.metroPort };
|
|
1648
|
-
console.log(JSON.stringify(payload));
|
|
1649
|
-
return;
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
if (opts.platform === 'ios') {
|
|
1653
|
-
console.log(platformEntry.deviceUdid);
|
|
1654
|
-
} else {
|
|
1655
|
-
console.log(`emulator-${platformEntry.consolePort}`);
|
|
1656
|
-
}
|
|
1657
|
-
});
|
|
1658
|
-
}
|
|
1659
|
-
```
|
|
1660
|
-
|
|
1661
|
-
- [ ] **Step 2: Wire into bin/cli.js**
|
|
1662
|
-
|
|
1663
|
-
Replace `bin/cli.js` with:
|
|
1664
|
-
|
|
1665
|
-
```javascript
|
|
1666
|
-
#!/usr/bin/env node
|
|
1667
|
-
import { Command } from 'commander';
|
|
1668
|
-
import deviceCommand from '../src/commands/device.js';
|
|
1669
|
-
|
|
1670
|
-
const program = new Command();
|
|
1671
|
-
program
|
|
1672
|
-
.name('rn-iso')
|
|
1673
|
-
.description('Isolated React Native dev environments per project/worktree')
|
|
1674
|
-
.version('0.1.0');
|
|
1675
|
-
|
|
1676
|
-
deviceCommand(program);
|
|
1677
|
-
|
|
1678
|
-
program.parse();
|
|
1679
|
-
```
|
|
1680
|
-
|
|
1681
|
-
- [ ] **Step 3: Smoke test (no project assigned, expect error)**
|
|
1682
|
-
|
|
1683
|
-
Run: `cd /tmp && node /Users/janicduplessis/Developer/rn-iso/bin/cli.js device`
|
|
1684
|
-
Expected: stderr contains "Not in a React Native project"; exit code 1.
|
|
1685
|
-
|
|
1686
|
-
- [ ] **Step 4: Commit**
|
|
1687
|
-
|
|
1688
|
-
```bash
|
|
1689
|
-
git add src/commands/device.js bin/cli.js
|
|
1690
|
-
git commit -m "feat: rn-iso device command"
|
|
1691
|
-
```
|
|
1692
|
-
|
|
1693
|
-
---
|
|
1694
|
-
|
|
1695
|
-
## Task 11: `rn-iso ios` command — ensure sim assigned, booted, app installed, Metro running
|
|
1696
|
-
|
|
1697
|
-
This is the central user flow. It composes everything built so far.
|
|
1698
|
-
|
|
1699
|
-
**Files:**
|
|
1700
|
-
- Create: `src/commands/ios.js`
|
|
1701
|
-
- Modify: `bin/cli.js`
|
|
1702
|
-
|
|
1703
|
-
- [ ] **Step 1: Implement src/commands/ios.js**
|
|
1704
|
-
|
|
1705
|
-
```javascript
|
|
1706
|
-
// src/commands/ios.js
|
|
1707
|
-
import chalk from 'chalk';
|
|
1708
|
-
import prompts from 'prompts';
|
|
1709
|
-
import { findProjectRoot, detectIsExpo, detectBundleId, detectAndroidPackage } from '../project.js';
|
|
1710
|
-
import { getProject, upsertProject, setMetro, setDevice, allClaimedDevices } from '../config.js';
|
|
1711
|
-
import { allocatePort } from '../ports.js';
|
|
1712
|
-
import { selectIosDevice, bootIosSim, listIosDeviceTypes, listIosRuntimes, createIosSim } from '../sim/ios.js';
|
|
1713
|
-
import { ensureMetro } from '../metro.js';
|
|
1714
|
-
import { buildIosCommand, resolveSimNameByUdid } from '../runner.js';
|
|
1715
|
-
import { getExecutor } from '../exec.js';
|
|
1716
|
-
|
|
1717
|
-
export default function iosCommand(program) {
|
|
1718
|
-
program
|
|
1719
|
-
.command('ios')
|
|
1720
|
-
.description('Ensure a dedicated iOS simulator + Metro server for the current project; build/install if needed')
|
|
1721
|
-
.option('--device-type <name>', 'Device type identifier for new sim (e.g. "iPhone 15 Pro")')
|
|
1722
|
-
.option('--auto', 'Non-interactive: boot a fresh sim if none available, no prompts')
|
|
1723
|
-
.option('--no-install', 'Skip the build/install step (assume app is already installed)')
|
|
1724
|
-
.action(async (opts) => {
|
|
1725
|
-
const root = findProjectRoot(process.cwd());
|
|
1726
|
-
if (!root) {
|
|
1727
|
-
console.error(chalk.red('Not in a React Native project (no package.json found).'));
|
|
1728
|
-
process.exit(1);
|
|
1729
|
-
}
|
|
1730
|
-
|
|
1731
|
-
const bundleId = detectBundleId(root);
|
|
1732
|
-
const androidPackage = detectAndroidPackage(root);
|
|
1733
|
-
const isExpo = detectIsExpo(root);
|
|
1734
|
-
if (!bundleId) {
|
|
1735
|
-
console.error(chalk.red('Could not detect iOS bundle identifier. v1 requires app.json with expo.ios.bundleIdentifier set.'));
|
|
1736
|
-
process.exit(1);
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
// Register or update the project entry.
|
|
1740
|
-
let proj = getProject(root);
|
|
1741
|
-
if (!proj) {
|
|
1742
|
-
upsertProject(root, { bundleId, androidPackage, isExpo });
|
|
1743
|
-
} else {
|
|
1744
|
-
upsertProject(root, { bundleId, androidPackage, isExpo });
|
|
1745
|
-
}
|
|
1746
|
-
proj = getProject(root);
|
|
1747
|
-
|
|
1748
|
-
// Allocate Metro port if not yet assigned.
|
|
1749
|
-
if (!proj.metroPort) {
|
|
1750
|
-
const port = await allocatePort(root);
|
|
1751
|
-
setMetro(root, port, null);
|
|
1752
|
-
proj = getProject(root);
|
|
1753
|
-
console.log(chalk.dim(`Allocated Metro port: ${port}`));
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
// Pick (or reuse) a simulator.
|
|
1757
|
-
const claimed = allClaimedDevices().iosUdids.filter(u => u !== proj.platforms?.ios?.deviceUdid);
|
|
1758
|
-
const selection = selectIosDevice({
|
|
1759
|
-
existingUdid: proj.platforms?.ios?.deviceUdid || null,
|
|
1760
|
-
claimedUdids: claimed,
|
|
1761
|
-
});
|
|
1762
|
-
|
|
1763
|
-
let udid;
|
|
1764
|
-
if (selection.kind === 'reuse') {
|
|
1765
|
-
udid = selection.udid;
|
|
1766
|
-
if (selection.state !== 'Booted') {
|
|
1767
|
-
console.log(chalk.dim(`Booting assigned sim ${udid}...`));
|
|
1768
|
-
bootIosSim(udid);
|
|
1769
|
-
} else {
|
|
1770
|
-
console.log(chalk.dim(`Reusing assigned sim ${udid} (already booted)`));
|
|
1771
|
-
}
|
|
1772
|
-
} else if (selection.kind === 'allocate') {
|
|
1773
|
-
udid = selection.udid;
|
|
1774
|
-
console.log(chalk.green(`Assigned booted sim ${udid}`));
|
|
1775
|
-
} else {
|
|
1776
|
-
// needsBoot: nothing booted+unclaimed.
|
|
1777
|
-
udid = await bootNewSim({ auto: opts.auto, deviceType: opts.deviceType });
|
|
1778
|
-
console.log(chalk.green(`Booted new sim ${udid}`));
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
setDevice(root, 'ios', { deviceUdid: udid });
|
|
1782
|
-
|
|
1783
|
-
// Ensure Metro is running.
|
|
1784
|
-
const metro = await ensureMetro({ projectPath: root, isExpo, port: proj.metroPort });
|
|
1785
|
-
if (metro.alreadyRunning) {
|
|
1786
|
-
console.log(chalk.dim(`Metro already running on port ${proj.metroPort}`));
|
|
1787
|
-
} else {
|
|
1788
|
-
setMetro(root, proj.metroPort, metro.pid);
|
|
1789
|
-
console.log(chalk.green(`Metro started (pid ${metro.pid}, port ${proj.metroPort}) — logs at ~/.rn-iso/logs/`));
|
|
1790
|
-
}
|
|
1791
|
-
|
|
1792
|
-
// Build/install/launch unless --no-install.
|
|
1793
|
-
if (opts.install !== false) {
|
|
1794
|
-
const simName = isExpo ? null : resolveSimNameByUdid(udid);
|
|
1795
|
-
const cmd = buildIosCommand({ isExpo, udid, port: proj.metroPort, simName });
|
|
1796
|
-
console.log(chalk.dim(`> ${cmd}`));
|
|
1797
|
-
// Stream the build output via spawn-with-inherit-stdio.
|
|
1798
|
-
const exec = getExecutor();
|
|
1799
|
-
const child = exec.spawn('sh', ['-c', cmd], { cwd: root, stdio: 'inherit' });
|
|
1800
|
-
await new Promise((resolve, reject) => {
|
|
1801
|
-
child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`Build failed (exit ${code})`)));
|
|
1802
|
-
});
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
console.log(chalk.green(`\n✓ iOS ready on sim ${udid}, Metro port ${proj.metroPort}`));
|
|
1806
|
-
});
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
|
-
async function bootNewSim({ auto, deviceType }) {
|
|
1810
|
-
const types = listIosDeviceTypes().filter(t => t.identifier.includes('iPhone'));
|
|
1811
|
-
const runtimes = listIosRuntimes();
|
|
1812
|
-
if (runtimes.length === 0) throw new Error('No iOS runtimes installed; install one via Xcode.');
|
|
1813
|
-
const latestRuntime = runtimes.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }))[0];
|
|
1814
|
-
|
|
1815
|
-
let chosenType;
|
|
1816
|
-
if (deviceType) {
|
|
1817
|
-
chosenType = types.find(t => t.name === deviceType || t.identifier === deviceType);
|
|
1818
|
-
if (!chosenType) throw new Error(`Device type not found: ${deviceType}`);
|
|
1819
|
-
} else if (auto) {
|
|
1820
|
-
chosenType = types.find(t => t.name === 'iPhone 15 Pro') || types[0];
|
|
1821
|
-
} else {
|
|
1822
|
-
const choices = types.map(t => ({ title: t.name, value: t.identifier }));
|
|
1823
|
-
const answer = await prompts({
|
|
1824
|
-
type: 'select',
|
|
1825
|
-
name: 'id',
|
|
1826
|
-
message: 'No booted simulator available. Pick a device type to boot:',
|
|
1827
|
-
choices,
|
|
1828
|
-
});
|
|
1829
|
-
if (!answer.id) throw new Error('Cancelled.');
|
|
1830
|
-
chosenType = types.find(t => t.identifier === answer.id);
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
const udid = createIosSim(chosenType.identifier, latestRuntime.identifier);
|
|
1834
|
-
bootIosSim(udid);
|
|
1835
|
-
return udid;
|
|
1836
|
-
}
|
|
1837
|
-
```
|
|
1838
|
-
|
|
1839
|
-
- [ ] **Step 2: Wire into bin/cli.js**
|
|
1840
|
-
|
|
1841
|
-
```javascript
|
|
1842
|
-
#!/usr/bin/env node
|
|
1843
|
-
import { Command } from 'commander';
|
|
1844
|
-
import deviceCommand from '../src/commands/device.js';
|
|
1845
|
-
import iosCommand from '../src/commands/ios.js';
|
|
1846
|
-
|
|
1847
|
-
const program = new Command();
|
|
1848
|
-
program
|
|
1849
|
-
.name('rn-iso')
|
|
1850
|
-
.description('Isolated React Native dev environments per project/worktree')
|
|
1851
|
-
.version('0.1.0');
|
|
1852
|
-
|
|
1853
|
-
deviceCommand(program);
|
|
1854
|
-
iosCommand(program);
|
|
1855
|
-
|
|
1856
|
-
program.parse();
|
|
1857
|
-
```
|
|
1858
|
-
|
|
1859
|
-
- [ ] **Step 3: Manual verification**
|
|
1860
|
-
|
|
1861
|
-
In a real Expo project (or test fixture):
|
|
1862
|
-
|
|
1863
|
-
```bash
|
|
1864
|
-
cd /path/to/some-expo-app
|
|
1865
|
-
node /Users/janicduplessis/Developer/rn-iso/bin/cli.js ios --no-install
|
|
1866
|
-
```
|
|
1867
|
-
|
|
1868
|
-
Expected: Metro starts, a sim is assigned (or booted), config is updated. Verify by inspecting `~/.rn-iso/config.json` and the log file at `~/.rn-iso/logs/`.
|
|
1869
|
-
|
|
1870
|
-
If no booted sim and no `--auto`, you should get a picker prompt.
|
|
1871
|
-
|
|
1872
|
-
- [ ] **Step 4: Commit**
|
|
1873
|
-
|
|
1874
|
-
```bash
|
|
1875
|
-
git add src/commands/ios.js bin/cli.js
|
|
1876
|
-
git commit -m "feat: rn-iso ios command — sim allocation, Metro, build dispatch"
|
|
1877
|
-
```
|
|
1878
|
-
|
|
1879
|
-
---
|
|
1880
|
-
|
|
1881
|
-
## Task 12: `rn-iso android` command
|
|
1882
|
-
|
|
1883
|
-
Mirror of `ios.js` but with Android specifics.
|
|
1884
|
-
|
|
1885
|
-
**Files:**
|
|
1886
|
-
- Create: `src/commands/android.js`
|
|
1887
|
-
- Modify: `bin/cli.js`
|
|
1888
|
-
|
|
1889
|
-
- [ ] **Step 1: Implement src/commands/android.js**
|
|
1890
|
-
|
|
1891
|
-
```javascript
|
|
1892
|
-
// src/commands/android.js
|
|
1893
|
-
import chalk from 'chalk';
|
|
1894
|
-
import { findProjectRoot, detectIsExpo, detectBundleId, detectAndroidPackage } from '../project.js';
|
|
1895
|
-
import { getProject, upsertProject, setMetro, setDevice, allClaimedDevices } from '../config.js';
|
|
1896
|
-
import { allocatePort } from '../ports.js';
|
|
1897
|
-
import { selectAndroidDevice, bootAndroidEmulator, waitForBoot, adbReverse, listAdbDevices } from '../sim/android.js';
|
|
1898
|
-
import { ensureMetro } from '../metro.js';
|
|
1899
|
-
import { buildAndroidCommand } from '../runner.js';
|
|
1900
|
-
import { getExecutor } from '../exec.js';
|
|
1901
|
-
|
|
1902
|
-
export default function androidCommand(program) {
|
|
1903
|
-
program
|
|
1904
|
-
.command('android')
|
|
1905
|
-
.description('Ensure a dedicated Android emulator + Metro for the current project; build/install if needed')
|
|
1906
|
-
.option('--no-install', 'Skip the build/install step')
|
|
1907
|
-
.action(async (opts) => {
|
|
1908
|
-
const root = findProjectRoot(process.cwd());
|
|
1909
|
-
if (!root) {
|
|
1910
|
-
console.error(chalk.red('Not in a React Native project (no package.json found).'));
|
|
1911
|
-
process.exit(1);
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
const bundleId = detectBundleId(root);
|
|
1915
|
-
const androidPackage = detectAndroidPackage(root);
|
|
1916
|
-
const isExpo = detectIsExpo(root);
|
|
1917
|
-
if (!androidPackage) {
|
|
1918
|
-
console.error(chalk.red('Could not detect Android package. v1 requires app.json with expo.android.package set.'));
|
|
1919
|
-
process.exit(1);
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
let proj = getProject(root);
|
|
1923
|
-
if (!proj) {
|
|
1924
|
-
upsertProject(root, { bundleId, androidPackage, isExpo });
|
|
1925
|
-
} else {
|
|
1926
|
-
upsertProject(root, { bundleId, androidPackage, isExpo });
|
|
1927
|
-
}
|
|
1928
|
-
proj = getProject(root);
|
|
1929
|
-
|
|
1930
|
-
if (!proj.metroPort) {
|
|
1931
|
-
const port = await allocatePort(root);
|
|
1932
|
-
setMetro(root, port, null);
|
|
1933
|
-
proj = getProject(root);
|
|
1934
|
-
console.log(chalk.dim(`Allocated Metro port: ${port}`));
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
const claimed = allClaimedDevices();
|
|
1938
|
-
const myAvd = proj.platforms?.android?.avdName || null;
|
|
1939
|
-
const myPort = proj.platforms?.android?.consolePort || null;
|
|
1940
|
-
const claimedAvds = claimed.androidAvds.filter(a => a !== myAvd);
|
|
1941
|
-
const claimedPorts = claimed.androidConsolePorts.filter(p => p !== myPort);
|
|
1942
|
-
|
|
1943
|
-
const selection = selectAndroidDevice({
|
|
1944
|
-
existingAvd: myAvd,
|
|
1945
|
-
existingConsolePort: myPort,
|
|
1946
|
-
claimedAvds,
|
|
1947
|
-
claimedConsolePorts: claimedPorts,
|
|
1948
|
-
});
|
|
1949
|
-
|
|
1950
|
-
if (selection.kind === 'noAvd') {
|
|
1951
|
-
console.error(chalk.red(
|
|
1952
|
-
'No AVDs available (or all are claimed by other projects). ' +
|
|
1953
|
-
'Create one via Android Studio (Tools → Device Manager).'
|
|
1954
|
-
));
|
|
1955
|
-
process.exit(1);
|
|
1956
|
-
}
|
|
1957
|
-
|
|
1958
|
-
const { avdName, consolePort, isRunning } = selection;
|
|
1959
|
-
const serial = `emulator-${consolePort}`;
|
|
1960
|
-
|
|
1961
|
-
if (!isRunning) {
|
|
1962
|
-
console.log(chalk.dim(`Booting emulator ${avdName} on port ${consolePort}...`));
|
|
1963
|
-
bootAndroidEmulator(avdName, consolePort);
|
|
1964
|
-
console.log(chalk.dim('Waiting for boot to complete (this can take 10-30s)...'));
|
|
1965
|
-
const ok = await waitForBoot(serial, 120000);
|
|
1966
|
-
if (!ok) {
|
|
1967
|
-
console.error(chalk.red(`Emulator ${serial} did not finish booting within 2 minutes.`));
|
|
1968
|
-
process.exit(1);
|
|
1969
|
-
}
|
|
1970
|
-
} else {
|
|
1971
|
-
console.log(chalk.dim(`Reusing running emulator ${serial}`));
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
setDevice(root, 'android', { avdName, consolePort });
|
|
1975
|
-
|
|
1976
|
-
const metro = await ensureMetro({ projectPath: root, isExpo, port: proj.metroPort });
|
|
1977
|
-
if (metro.alreadyRunning) {
|
|
1978
|
-
console.log(chalk.dim(`Metro already running on port ${proj.metroPort}`));
|
|
1979
|
-
} else {
|
|
1980
|
-
setMetro(root, proj.metroPort, metro.pid);
|
|
1981
|
-
console.log(chalk.green(`Metro started (pid ${metro.pid}, port ${proj.metroPort})`));
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
adbReverse(serial, proj.metroPort);
|
|
1985
|
-
console.log(chalk.dim(`adb reverse tcp:${proj.metroPort} configured for ${serial}`));
|
|
1986
|
-
|
|
1987
|
-
if (opts.install !== false) {
|
|
1988
|
-
const cmd = buildAndroidCommand({ isExpo, serial, port: proj.metroPort });
|
|
1989
|
-
console.log(chalk.dim(`> ${cmd}`));
|
|
1990
|
-
const exec = getExecutor();
|
|
1991
|
-
const child = exec.spawn('sh', ['-c', cmd], { cwd: root, stdio: 'inherit' });
|
|
1992
|
-
await new Promise((resolve, reject) => {
|
|
1993
|
-
child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`Build failed (exit ${code})`)));
|
|
1994
|
-
});
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
console.log(chalk.green(`\n✓ Android ready on ${serial}, Metro port ${proj.metroPort}`));
|
|
1998
|
-
});
|
|
1999
|
-
}
|
|
2000
|
-
```
|
|
2001
|
-
|
|
2002
|
-
- [ ] **Step 2: Wire into bin/cli.js**
|
|
2003
|
-
|
|
2004
|
-
```javascript
|
|
2005
|
-
#!/usr/bin/env node
|
|
2006
|
-
import { Command } from 'commander';
|
|
2007
|
-
import deviceCommand from '../src/commands/device.js';
|
|
2008
|
-
import iosCommand from '../src/commands/ios.js';
|
|
2009
|
-
import androidCommand from '../src/commands/android.js';
|
|
2010
|
-
|
|
2011
|
-
const program = new Command();
|
|
2012
|
-
program
|
|
2013
|
-
.name('rn-iso')
|
|
2014
|
-
.description('Isolated React Native dev environments per project/worktree')
|
|
2015
|
-
.version('0.1.0');
|
|
2016
|
-
|
|
2017
|
-
deviceCommand(program);
|
|
2018
|
-
iosCommand(program);
|
|
2019
|
-
androidCommand(program);
|
|
2020
|
-
|
|
2021
|
-
program.parse();
|
|
2022
|
-
```
|
|
2023
|
-
|
|
2024
|
-
- [ ] **Step 3: Manual verification**
|
|
2025
|
-
|
|
2026
|
-
```bash
|
|
2027
|
-
cd /path/to/some-expo-app
|
|
2028
|
-
node /Users/janicduplessis/Developer/rn-iso/bin/cli.js android --no-install
|
|
2029
|
-
```
|
|
2030
|
-
|
|
2031
|
-
Expected: emulator boots if needed, Metro starts, adb reverse runs, config is updated.
|
|
2032
|
-
|
|
2033
|
-
- [ ] **Step 4: Commit**
|
|
2034
|
-
|
|
2035
|
-
```bash
|
|
2036
|
-
git add src/commands/android.js bin/cli.js
|
|
2037
|
-
git commit -m "feat: rn-iso android command — emulator allocation, Metro, build dispatch"
|
|
2038
|
-
```
|
|
2039
|
-
|
|
2040
|
-
---
|
|
2041
|
-
|
|
2042
|
-
## Task 13: `rn-iso start` / `stop` / `logs` commands
|
|
2043
|
-
|
|
2044
|
-
Metro lifecycle commands without platform action.
|
|
2045
|
-
|
|
2046
|
-
**Files:**
|
|
2047
|
-
- Create: `src/commands/start.js`
|
|
2048
|
-
- Create: `src/commands/stop.js`
|
|
2049
|
-
- Create: `src/commands/logs.js`
|
|
2050
|
-
- Modify: `bin/cli.js`
|
|
2051
|
-
|
|
2052
|
-
- [ ] **Step 1: Implement src/commands/start.js**
|
|
2053
|
-
|
|
2054
|
-
```javascript
|
|
2055
|
-
// src/commands/start.js
|
|
2056
|
-
import chalk from 'chalk';
|
|
2057
|
-
import { findProjectRoot, detectIsExpo, detectBundleId, detectAndroidPackage } from '../project.js';
|
|
2058
|
-
import { getProject, upsertProject, setMetro } from '../config.js';
|
|
2059
|
-
import { allocatePort } from '../ports.js';
|
|
2060
|
-
import { ensureMetro } from '../metro.js';
|
|
2061
|
-
|
|
2062
|
-
export default function startCommand(program) {
|
|
2063
|
-
program
|
|
2064
|
-
.command('start')
|
|
2065
|
-
.description('Ensure Metro is running for the current project (no platform action)')
|
|
2066
|
-
.action(async () => {
|
|
2067
|
-
const root = findProjectRoot(process.cwd());
|
|
2068
|
-
if (!root) {
|
|
2069
|
-
console.error(chalk.red('Not in a React Native project (no package.json found).'));
|
|
2070
|
-
process.exit(1);
|
|
2071
|
-
}
|
|
2072
|
-
const isExpo = detectIsExpo(root);
|
|
2073
|
-
|
|
2074
|
-
let proj = getProject(root);
|
|
2075
|
-
if (!proj) {
|
|
2076
|
-
upsertProject(root, {
|
|
2077
|
-
bundleId: detectBundleId(root),
|
|
2078
|
-
androidPackage: detectAndroidPackage(root),
|
|
2079
|
-
isExpo,
|
|
2080
|
-
});
|
|
2081
|
-
proj = getProject(root);
|
|
2082
|
-
}
|
|
2083
|
-
if (!proj.metroPort) {
|
|
2084
|
-
const port = await allocatePort(root);
|
|
2085
|
-
setMetro(root, port, null);
|
|
2086
|
-
proj = getProject(root);
|
|
2087
|
-
}
|
|
2088
|
-
|
|
2089
|
-
const metro = await ensureMetro({ projectPath: root, isExpo, port: proj.metroPort });
|
|
2090
|
-
if (metro.alreadyRunning) {
|
|
2091
|
-
console.log(chalk.dim(`Metro already running on port ${proj.metroPort}`));
|
|
2092
|
-
} else {
|
|
2093
|
-
setMetro(root, proj.metroPort, metro.pid);
|
|
2094
|
-
console.log(chalk.green(`Metro started (pid ${metro.pid}, port ${proj.metroPort})`));
|
|
2095
|
-
}
|
|
2096
|
-
});
|
|
2097
|
-
}
|
|
2098
|
-
```
|
|
2099
|
-
|
|
2100
|
-
- [ ] **Step 2: Implement src/commands/stop.js**
|
|
2101
|
-
|
|
2102
|
-
```javascript
|
|
2103
|
-
// src/commands/stop.js
|
|
2104
|
-
import chalk from 'chalk';
|
|
2105
|
-
import { findProjectRoot } from '../project.js';
|
|
2106
|
-
import { getProject, setMetro } from '../config.js';
|
|
2107
|
-
import { killMetroByPid } from '../metro.js';
|
|
2108
|
-
|
|
2109
|
-
export default function stopCommand(program) {
|
|
2110
|
-
program
|
|
2111
|
-
.command('stop')
|
|
2112
|
-
.description('Kill the Metro process for the current project')
|
|
2113
|
-
.action(() => {
|
|
2114
|
-
const root = findProjectRoot(process.cwd());
|
|
2115
|
-
if (!root) {
|
|
2116
|
-
console.error(chalk.red('Not in a React Native project.'));
|
|
2117
|
-
process.exit(1);
|
|
2118
|
-
}
|
|
2119
|
-
const proj = getProject(root);
|
|
2120
|
-
if (!proj?.metroPid) {
|
|
2121
|
-
console.log(chalk.dim('No Metro PID recorded for this project.'));
|
|
2122
|
-
return;
|
|
2123
|
-
}
|
|
2124
|
-
const ok = killMetroByPid(proj.metroPid);
|
|
2125
|
-
setMetro(root, proj.metroPort, null);
|
|
2126
|
-
console.log(ok
|
|
2127
|
-
? chalk.green(`Killed Metro pid ${proj.metroPid}`)
|
|
2128
|
-
: chalk.dim(`Metro pid ${proj.metroPid} was not alive`));
|
|
2129
|
-
});
|
|
2130
|
-
}
|
|
2131
|
-
```
|
|
2132
|
-
|
|
2133
|
-
- [ ] **Step 3: Implement src/commands/logs.js**
|
|
2134
|
-
|
|
2135
|
-
```javascript
|
|
2136
|
-
// src/commands/logs.js
|
|
2137
|
-
import chalk from 'chalk';
|
|
2138
|
-
import { findProjectRoot } from '../project.js';
|
|
2139
|
-
import { logFileExists } from '../metro.js';
|
|
2140
|
-
import { getExecutor } from '../exec.js';
|
|
2141
|
-
|
|
2142
|
-
export default function logsCommand(program) {
|
|
2143
|
-
program
|
|
2144
|
-
.command('logs')
|
|
2145
|
-
.description('Tail the Metro log file for the current project')
|
|
2146
|
-
.action(() => {
|
|
2147
|
-
const root = findProjectRoot(process.cwd());
|
|
2148
|
-
if (!root) {
|
|
2149
|
-
console.error(chalk.red('Not in a React Native project.'));
|
|
2150
|
-
process.exit(1);
|
|
2151
|
-
}
|
|
2152
|
-
const path = logFileExists(root);
|
|
2153
|
-
if (!path) {
|
|
2154
|
-
console.error(chalk.red('No Metro log file found. Have you run `rn-iso start` or `rn-iso ios/android`?'));
|
|
2155
|
-
process.exit(1);
|
|
2156
|
-
}
|
|
2157
|
-
console.log(chalk.dim(`Tailing ${path}\n`));
|
|
2158
|
-
const exec = getExecutor();
|
|
2159
|
-
const child = exec.spawn('tail', ['-f', path], { stdio: 'inherit' });
|
|
2160
|
-
// Forward SIGINT cleanly
|
|
2161
|
-
process.on('SIGINT', () => child.kill('SIGINT'));
|
|
2162
|
-
});
|
|
2163
|
-
}
|
|
2164
|
-
```
|
|
2165
|
-
|
|
2166
|
-
- [ ] **Step 4: Wire into bin/cli.js**
|
|
2167
|
-
|
|
2168
|
-
```javascript
|
|
2169
|
-
#!/usr/bin/env node
|
|
2170
|
-
import { Command } from 'commander';
|
|
2171
|
-
import deviceCommand from '../src/commands/device.js';
|
|
2172
|
-
import iosCommand from '../src/commands/ios.js';
|
|
2173
|
-
import androidCommand from '../src/commands/android.js';
|
|
2174
|
-
import startCommand from '../src/commands/start.js';
|
|
2175
|
-
import stopCommand from '../src/commands/stop.js';
|
|
2176
|
-
import logsCommand from '../src/commands/logs.js';
|
|
2177
|
-
|
|
2178
|
-
const program = new Command();
|
|
2179
|
-
program
|
|
2180
|
-
.name('rn-iso')
|
|
2181
|
-
.description('Isolated React Native dev environments per project/worktree')
|
|
2182
|
-
.version('0.1.0');
|
|
2183
|
-
|
|
2184
|
-
deviceCommand(program);
|
|
2185
|
-
iosCommand(program);
|
|
2186
|
-
androidCommand(program);
|
|
2187
|
-
startCommand(program);
|
|
2188
|
-
stopCommand(program);
|
|
2189
|
-
logsCommand(program);
|
|
2190
|
-
|
|
2191
|
-
program.parse();
|
|
2192
|
-
```
|
|
2193
|
-
|
|
2194
|
-
- [ ] **Step 5: Manual verification**
|
|
2195
|
-
|
|
2196
|
-
In a project: `node bin/cli.js start`, then `node bin/cli.js stop`, then `node bin/cli.js logs` (will fail if no log yet, succeed after a start).
|
|
2197
|
-
|
|
2198
|
-
- [ ] **Step 6: Commit**
|
|
2199
|
-
|
|
2200
|
-
```bash
|
|
2201
|
-
git add src/commands/start.js src/commands/stop.js src/commands/logs.js bin/cli.js
|
|
2202
|
-
git commit -m "feat: rn-iso start / stop / logs commands"
|
|
2203
|
-
```
|
|
2204
|
-
|
|
2205
|
-
---
|
|
2206
|
-
|
|
2207
|
-
## Task 14: `rn-iso status` command
|
|
2208
|
-
|
|
2209
|
-
Show all projects' state.
|
|
2210
|
-
|
|
2211
|
-
**Files:**
|
|
2212
|
-
- Create: `src/commands/status.js`
|
|
2213
|
-
- Modify: `bin/cli.js`
|
|
2214
|
-
|
|
2215
|
-
- [ ] **Step 1: Implement src/commands/status.js**
|
|
2216
|
-
|
|
2217
|
-
```javascript
|
|
2218
|
-
// src/commands/status.js
|
|
2219
|
-
import chalk from 'chalk';
|
|
2220
|
-
import { loadConfig } from '../config.js';
|
|
2221
|
-
import { isMetroRunning } from '../ports.js';
|
|
2222
|
-
import { isPidAlive } from '../metro.js';
|
|
2223
|
-
import { findProjectRoot } from '../project.js';
|
|
2224
|
-
|
|
2225
|
-
export default function statusCommand(program) {
|
|
2226
|
-
program
|
|
2227
|
-
.command('status')
|
|
2228
|
-
.description('Show all rn-iso project assignments and Metro state')
|
|
2229
|
-
.action(async () => {
|
|
2230
|
-
const cfg = loadConfig();
|
|
2231
|
-
if (!cfg || Object.keys(cfg.projects).length === 0) {
|
|
2232
|
-
console.log(chalk.dim('No projects registered.'));
|
|
2233
|
-
return;
|
|
2234
|
-
}
|
|
2235
|
-
|
|
2236
|
-
const cwdRoot = findProjectRoot(process.cwd());
|
|
2237
|
-
|
|
2238
|
-
for (const [path, proj] of Object.entries(cfg.projects)) {
|
|
2239
|
-
const isCurrent = path === cwdRoot;
|
|
2240
|
-
const header = isCurrent ? chalk.bold.cyan(`* ${path}`) : path;
|
|
2241
|
-
console.log('\n' + header);
|
|
2242
|
-
console.log(chalk.dim(` app: ${proj.bundleId} (${proj.isExpo ? 'expo' : 'bare'})`));
|
|
2243
|
-
|
|
2244
|
-
if (proj.metroPort) {
|
|
2245
|
-
const running = await isMetroRunning(proj.metroPort);
|
|
2246
|
-
const pidLive = isPidAlive(proj.metroPid);
|
|
2247
|
-
const label = running
|
|
2248
|
-
? chalk.green('running')
|
|
2249
|
-
: pidLive ? chalk.yellow('pid alive but not responding') : chalk.dim('stopped');
|
|
2250
|
-
console.log(` metro: port ${proj.metroPort} pid ${proj.metroPid ?? '?'} (${label})`);
|
|
2251
|
-
} else {
|
|
2252
|
-
console.log(chalk.dim(' metro: unassigned'));
|
|
2253
|
-
}
|
|
2254
|
-
|
|
2255
|
-
const ios = proj.platforms?.ios;
|
|
2256
|
-
if (ios) console.log(` ios: ${chalk.cyan(ios.deviceUdid)}`);
|
|
2257
|
-
const android = proj.platforms?.android;
|
|
2258
|
-
if (android) console.log(` android: ${chalk.cyan(android.avdName)} on emulator-${android.consolePort}`);
|
|
2259
|
-
}
|
|
2260
|
-
console.log('');
|
|
2261
|
-
});
|
|
2262
|
-
}
|
|
2263
|
-
```
|
|
2264
|
-
|
|
2265
|
-
- [ ] **Step 2: Wire into bin/cli.js**
|
|
2266
|
-
|
|
2267
|
-
Add `import statusCommand from '../src/commands/status.js';` and `statusCommand(program);`.
|
|
2268
|
-
|
|
2269
|
-
- [ ] **Step 3: Manual verification**
|
|
2270
|
-
|
|
2271
|
-
After running `rn-iso ios` in one or two projects: `node bin/cli.js status`. Should list each with their port + sim.
|
|
2272
|
-
|
|
2273
|
-
- [ ] **Step 4: Commit**
|
|
2274
|
-
|
|
2275
|
-
```bash
|
|
2276
|
-
git add src/commands/status.js bin/cli.js
|
|
2277
|
-
git commit -m "feat: rn-iso status command"
|
|
2278
|
-
```
|
|
2279
|
-
|
|
2280
|
-
---
|
|
2281
|
-
|
|
2282
|
-
## Task 15: `rn-iso release` and `rn-iso shutdown` commands
|
|
2283
|
-
|
|
2284
|
-
**Files:**
|
|
2285
|
-
- Create: `src/commands/release.js`
|
|
2286
|
-
- Create: `src/commands/shutdown.js`
|
|
2287
|
-
- Modify: `bin/cli.js`
|
|
2288
|
-
|
|
2289
|
-
- [ ] **Step 1: Implement src/commands/release.js**
|
|
2290
|
-
|
|
2291
|
-
```javascript
|
|
2292
|
-
// src/commands/release.js
|
|
2293
|
-
import chalk from 'chalk';
|
|
2294
|
-
import { findProjectRoot } from '../project.js';
|
|
2295
|
-
import { getProject, clearDevice } from '../config.js';
|
|
2296
|
-
|
|
2297
|
-
export default function releaseCommand(program) {
|
|
2298
|
-
program
|
|
2299
|
-
.command('release')
|
|
2300
|
-
.description('Unbind device assignment(s) for the current project')
|
|
2301
|
-
.option('--platform <platform>', 'ios or android (default: both)')
|
|
2302
|
-
.action((opts) => {
|
|
2303
|
-
const root = findProjectRoot(process.cwd());
|
|
2304
|
-
if (!root) {
|
|
2305
|
-
console.error(chalk.red('Not in a React Native project.'));
|
|
2306
|
-
process.exit(1);
|
|
2307
|
-
}
|
|
2308
|
-
const proj = getProject(root);
|
|
2309
|
-
if (!proj) {
|
|
2310
|
-
console.log(chalk.dim('No project entry to release.'));
|
|
2311
|
-
return;
|
|
2312
|
-
}
|
|
2313
|
-
const platforms = opts.platform ? [opts.platform] : ['ios', 'android'];
|
|
2314
|
-
for (const p of platforms) {
|
|
2315
|
-
if (proj.platforms?.[p]) {
|
|
2316
|
-
clearDevice(root, p);
|
|
2317
|
-
console.log(chalk.green(`Released ${p} assignment.`));
|
|
2318
|
-
} else {
|
|
2319
|
-
console.log(chalk.dim(`No ${p} assignment to release.`));
|
|
2320
|
-
}
|
|
2321
|
-
}
|
|
2322
|
-
});
|
|
2323
|
-
}
|
|
2324
|
-
```
|
|
2325
|
-
|
|
2326
|
-
- [ ] **Step 2: Implement src/commands/shutdown.js**
|
|
2327
|
-
|
|
2328
|
-
```javascript
|
|
2329
|
-
// src/commands/shutdown.js
|
|
2330
|
-
import chalk from 'chalk';
|
|
2331
|
-
import { findProjectRoot } from '../project.js';
|
|
2332
|
-
import { getProject, clearDevice } from '../config.js';
|
|
2333
|
-
import { shutdownIosSim } from '../sim/ios.js';
|
|
2334
|
-
import { shutdownAndroidEmulator } from '../sim/android.js';
|
|
2335
|
-
|
|
2336
|
-
export default function shutdownCommand(program) {
|
|
2337
|
-
program
|
|
2338
|
-
.command('shutdown')
|
|
2339
|
-
.description('Release and shut down the simulator/emulator(s) for the current project')
|
|
2340
|
-
.option('--platform <platform>', 'ios or android (default: both)')
|
|
2341
|
-
.action((opts) => {
|
|
2342
|
-
const root = findProjectRoot(process.cwd());
|
|
2343
|
-
if (!root) {
|
|
2344
|
-
console.error(chalk.red('Not in a React Native project.'));
|
|
2345
|
-
process.exit(1);
|
|
2346
|
-
}
|
|
2347
|
-
const proj = getProject(root);
|
|
2348
|
-
if (!proj) {
|
|
2349
|
-
console.log(chalk.dim('No project entry.'));
|
|
2350
|
-
return;
|
|
2351
|
-
}
|
|
2352
|
-
const platforms = opts.platform ? [opts.platform] : ['ios', 'android'];
|
|
2353
|
-
for (const p of platforms) {
|
|
2354
|
-
const entry = proj.platforms?.[p];
|
|
2355
|
-
if (!entry) {
|
|
2356
|
-
console.log(chalk.dim(`No ${p} assignment.`));
|
|
2357
|
-
continue;
|
|
2358
|
-
}
|
|
2359
|
-
if (p === 'ios') {
|
|
2360
|
-
shutdownIosSim(entry.deviceUdid);
|
|
2361
|
-
console.log(chalk.green(`Shut down iOS sim ${entry.deviceUdid}`));
|
|
2362
|
-
} else {
|
|
2363
|
-
shutdownAndroidEmulator(`emulator-${entry.consolePort}`);
|
|
2364
|
-
console.log(chalk.green(`Shut down emulator-${entry.consolePort}`));
|
|
2365
|
-
}
|
|
2366
|
-
clearDevice(root, p);
|
|
2367
|
-
}
|
|
2368
|
-
});
|
|
2369
|
-
}
|
|
2370
|
-
```
|
|
2371
|
-
|
|
2372
|
-
- [ ] **Step 3: Wire into bin/cli.js**
|
|
2373
|
-
|
|
2374
|
-
Add imports and registrations for both.
|
|
2375
|
-
|
|
2376
|
-
- [ ] **Step 4: Commit**
|
|
2377
|
-
|
|
2378
|
-
```bash
|
|
2379
|
-
git add src/commands/release.js src/commands/shutdown.js bin/cli.js
|
|
2380
|
-
git commit -m "feat: rn-iso release and shutdown commands"
|
|
2381
|
-
```
|
|
2382
|
-
|
|
2383
|
-
---
|
|
2384
|
-
|
|
2385
|
-
## Task 16: `rn-iso prune` command
|
|
2386
|
-
|
|
2387
|
-
GC dead entries machine-wide.
|
|
2388
|
-
|
|
2389
|
-
**Files:**
|
|
2390
|
-
- Create: `src/commands/prune.js`
|
|
2391
|
-
- Modify: `bin/cli.js`
|
|
2392
|
-
|
|
2393
|
-
- [ ] **Step 1: Implement src/commands/prune.js**
|
|
2394
|
-
|
|
2395
|
-
```javascript
|
|
2396
|
-
// src/commands/prune.js
|
|
2397
|
-
import { existsSync } from 'fs';
|
|
2398
|
-
import chalk from 'chalk';
|
|
2399
|
-
import { loadConfig, removeProject, clearDevice } from '../config.js';
|
|
2400
|
-
import { listAllIosSims, shutdownIosSim } from '../sim/ios.js';
|
|
2401
|
-
import { listAvds, shutdownAndroidEmulator } from '../sim/android.js';
|
|
2402
|
-
|
|
2403
|
-
export default function pruneCommand(program) {
|
|
2404
|
-
program
|
|
2405
|
-
.command('prune')
|
|
2406
|
-
.description('Garbage-collect dead project entries and missing device assignments')
|
|
2407
|
-
.option('--shutdown', 'Also shut down sims/emulators referenced only by dropped entries')
|
|
2408
|
-
.action((opts) => {
|
|
2409
|
-
const cfg = loadConfig();
|
|
2410
|
-
if (!cfg?.projects) {
|
|
2411
|
-
console.log(chalk.dim('Nothing to prune.'));
|
|
2412
|
-
return;
|
|
2413
|
-
}
|
|
2414
|
-
|
|
2415
|
-
const allIosUdids = new Set(listAllIosSims().map(s => s.udid));
|
|
2416
|
-
const allAvds = new Set(listAvds());
|
|
2417
|
-
|
|
2418
|
-
const droppedSims = [];
|
|
2419
|
-
const droppedEmulators = [];
|
|
2420
|
-
|
|
2421
|
-
for (const [path, proj] of Object.entries(cfg.projects)) {
|
|
2422
|
-
// Drop entire project if its dir is gone.
|
|
2423
|
-
if (!existsSync(path)) {
|
|
2424
|
-
if (opts.shutdown) {
|
|
2425
|
-
if (proj.platforms?.ios?.deviceUdid) droppedSims.push(proj.platforms.ios.deviceUdid);
|
|
2426
|
-
if (proj.platforms?.android?.consolePort) droppedEmulators.push(proj.platforms.android.consolePort);
|
|
2427
|
-
}
|
|
2428
|
-
removeProject(path);
|
|
2429
|
-
console.log(chalk.yellow(`Dropped missing project: ${path}`));
|
|
2430
|
-
continue;
|
|
2431
|
-
}
|
|
2432
|
-
|
|
2433
|
-
// Drop iOS assignment if UDID no longer exists.
|
|
2434
|
-
if (proj.platforms?.ios && !allIosUdids.has(proj.platforms.ios.deviceUdid)) {
|
|
2435
|
-
clearDevice(path, 'ios');
|
|
2436
|
-
console.log(chalk.dim(`${path}: cleared stale iOS assignment ${proj.platforms.ios.deviceUdid}`));
|
|
2437
|
-
}
|
|
2438
|
-
// Drop Android assignment if AVD no longer exists.
|
|
2439
|
-
if (proj.platforms?.android && !allAvds.has(proj.platforms.android.avdName)) {
|
|
2440
|
-
clearDevice(path, 'android');
|
|
2441
|
-
console.log(chalk.dim(`${path}: cleared stale Android assignment ${proj.platforms.android.avdName}`));
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
|
-
|
|
2445
|
-
if (opts.shutdown) {
|
|
2446
|
-
for (const udid of droppedSims) shutdownIosSim(udid);
|
|
2447
|
-
for (const port of droppedEmulators) shutdownAndroidEmulator(`emulator-${port}`);
|
|
2448
|
-
}
|
|
2449
|
-
|
|
2450
|
-
console.log(chalk.green('Prune complete.'));
|
|
2451
|
-
});
|
|
2452
|
-
}
|
|
2453
|
-
```
|
|
2454
|
-
|
|
2455
|
-
- [ ] **Step 2: Wire into bin/cli.js**
|
|
2456
|
-
|
|
2457
|
-
Add import and registration for `pruneCommand`.
|
|
2458
|
-
|
|
2459
|
-
- [ ] **Step 3: Commit**
|
|
2460
|
-
|
|
2461
|
-
```bash
|
|
2462
|
-
git add src/commands/prune.js bin/cli.js
|
|
2463
|
-
git commit -m "feat: rn-iso prune command for GC"
|
|
2464
|
-
```
|
|
2465
|
-
|
|
2466
|
-
---
|
|
2467
|
-
|
|
2468
|
-
## Task 17: Skill file for AI agents
|
|
2469
|
-
|
|
2470
|
-
**Files:**
|
|
2471
|
-
- Create: `skill/SKILL.md`
|
|
2472
|
-
|
|
2473
|
-
- [ ] **Step 1: Write skill/SKILL.md**
|
|
2474
|
-
|
|
2475
|
-
```markdown
|
|
2476
|
-
---
|
|
2477
|
-
name: rn-iso
|
|
2478
|
-
description: Manage isolated React Native / Expo dev environments. Each project (or worktree) gets its own Metro server and dedicated simulator/emulator. Use to ensure the right simulator is booted with the right port, and to discover which device to target for UI interactions.
|
|
2479
|
-
user_invocable: true
|
|
2480
|
-
---
|
|
2481
|
-
|
|
2482
|
-
# rn-iso — Isolated RN Dev Environments
|
|
2483
|
-
|
|
2484
|
-
You are an AI agent working on a React Native / Expo project, possibly alongside other agents working on different projects or worktrees. Each project owns its own dedicated simulator and Metro server. There is no locking — your sim is yours.
|
|
2485
|
-
|
|
2486
|
-
## Core workflow
|
|
2487
|
-
|
|
2488
|
-
From the project root (or any subdirectory):
|
|
2489
|
-
|
|
2490
|
-
1. **Ensure the platform is ready** — `rn-iso ios` or `rn-iso android`. This:
|
|
2491
|
-
- Allocates a Metro port for the project (or reuses the assigned one)
|
|
2492
|
-
- Picks a dedicated simulator (or boots a new one)
|
|
2493
|
-
- Starts Metro detached
|
|
2494
|
-
- Builds and installs the app on the simulator
|
|
2495
|
-
|
|
2496
|
-
2. **Get the device target** — `rn-iso device --platform ios --json` returns:
|
|
2497
|
-
```json
|
|
2498
|
-
{"platform":"ios","udid":"ABC-...","metroPort":8083}
|
|
2499
|
-
```
|
|
2500
|
-
Use the UDID for any `agent-device` / `xcrun simctl` / `idb` calls. For Android, the `serial` field gives you `emulator-<port>` to use with `adb -s`.
|
|
2501
|
-
|
|
2502
|
-
3. **Interact with the device** — pass the UDID/serial to your UI tools. Never call `simctl boot` or `simctl <verb>` without `<UDID>` — `booted` could be the wrong sim.
|
|
2503
|
-
|
|
2504
|
-
## CRITICAL rules
|
|
2505
|
-
|
|
2506
|
-
- **Always use `rn-iso device` to discover your target.** Never assume `booted` is your sim — another project's simulator might be booted too.
|
|
2507
|
-
- **Always pass the UDID/serial explicitly** to `xcrun simctl` and `adb -s`. Examples:
|
|
2508
|
-
- `xcrun simctl io <UDID> screenshot out.png`
|
|
2509
|
-
- `adb -s emulator-5556 shell input tap 100 200`
|
|
2510
|
-
- **Don't call `release` or `shutdown`** unless the user explicitly asks. Other agents may be using neighboring sims; keep yours up so the user can come back to it.
|
|
2511
|
-
- **Don't manually start Metro on a different port.** `rn-iso start` (or `rn-iso ios/android`) already handles port assignment.
|
|
2512
|
-
- **For non-interactive / first-run scenarios**, pass `--auto` and optionally `--device-type "iPhone 15 Pro"`. Without these, `rn-iso ios` will prompt for a device type if no sims are booted.
|
|
2513
|
-
|
|
2514
|
-
## Typical agent workflow
|
|
2515
|
-
|
|
2516
|
-
```bash
|
|
2517
|
-
# Once per session — ensure the project's sim and Metro are up.
|
|
2518
|
-
rn-iso ios --auto
|
|
2519
|
-
|
|
2520
|
-
# Get the target.
|
|
2521
|
-
UDID=$(rn-iso device --platform ios)
|
|
2522
|
-
|
|
2523
|
-
# Use the target for UI interactions (delegate to agent-device or your tool of choice).
|
|
2524
|
-
xcrun simctl io "$UDID" screenshot /tmp/screen.png
|
|
2525
|
-
|
|
2526
|
-
# When you change app code, Metro hot-reloads automatically. No restart needed.
|
|
2527
|
-
# Only re-run `rn-iso ios` when you've changed native code or installed new native modules.
|
|
2528
|
-
```
|
|
2529
|
-
|
|
2530
|
-
## When things go wrong
|
|
2531
|
-
|
|
2532
|
-
- **"No rn-iso assignment for project"** — run `rn-iso ios` (or android) first.
|
|
2533
|
-
- **"Could not detect bundle identifier"** — your project's `app.json` is missing `expo.ios.bundleIdentifier`. Fix the app config.
|
|
2534
|
-
- **Metro port collision** — `rn-iso ios` should reclaim dead ports automatically. If you see "port busy by non-Metro process," another tool is using that port; close it.
|
|
2535
|
-
- **Sim was deleted** — `rn-iso ios` will detect the stale assignment and re-allocate. If not, run `rn-iso prune` then `rn-iso ios`.
|
|
2536
|
-
|
|
2537
|
-
## Other useful commands
|
|
2538
|
-
|
|
2539
|
-
- `rn-iso status` — show all projects and their state.
|
|
2540
|
-
- `rn-iso logs` — tail the Metro log for the current project.
|
|
2541
|
-
- `rn-iso stop` — kill the project's Metro (rare — usually leave it running).
|
|
2542
|
-
- `rn-iso prune` — GC dead entries machine-wide; safe to run periodically.
|
|
2543
|
-
|
|
2544
|
-
## Differences from `react-native-worktree`
|
|
2545
|
-
|
|
2546
|
-
`react-native-worktree` shares one simulator across worktrees with a mutex. `rn-iso` gives each project its own dedicated simulator — no locking, no contention. If both are installed, prefer `rn-iso` unless the user explicitly asks for the shared-sim model.
|
|
2547
|
-
```
|
|
2548
|
-
|
|
2549
|
-
- [ ] **Step 2: Commit**
|
|
2550
|
-
|
|
2551
|
-
```bash
|
|
2552
|
-
git add skill/SKILL.md
|
|
2553
|
-
git commit -m "docs: skill file for AI agent integration"
|
|
2554
|
-
```
|
|
2555
|
-
|
|
2556
|
-
---
|
|
2557
|
-
|
|
2558
|
-
## Task 18: README
|
|
2559
|
-
|
|
2560
|
-
**Files:**
|
|
2561
|
-
- Create: `README.md`
|
|
2562
|
-
|
|
2563
|
-
- [ ] **Step 1: Write README.md**
|
|
2564
|
-
|
|
2565
|
-
```markdown
|
|
2566
|
-
# rn-iso
|
|
2567
|
-
|
|
2568
|
-
Isolated React Native / Expo dev environments per project or worktree. Each project gets its own Metro server and dedicated simulator/emulator. Designed for running multiple AI coding agents in parallel without port or device collisions.
|
|
2569
|
-
|
|
2570
|
-
## Install
|
|
2571
|
-
|
|
2572
|
-
```bash
|
|
2573
|
-
npm install -g rn-iso
|
|
2574
|
-
```
|
|
2575
|
-
|
|
2576
|
-
For AI agents, install the skill:
|
|
2577
|
-
|
|
2578
|
-
```bash
|
|
2579
|
-
# Claude Code
|
|
2580
|
-
mkdir -p ~/.claude/skills/rn-iso && curl -fsSL https://raw.githubusercontent.com/.../rn-iso/main/skill/SKILL.md -o ~/.claude/skills/rn-iso/SKILL.md
|
|
2581
|
-
```
|
|
2582
|
-
|
|
2583
|
-
## Quick start
|
|
2584
|
-
|
|
2585
|
-
In any RN/Expo project directory:
|
|
2586
|
-
|
|
2587
|
-
```bash
|
|
2588
|
-
rn-iso ios # ensure sim, Metro, build/install
|
|
2589
|
-
rn-iso device # print the assigned UDID
|
|
2590
|
-
```
|
|
2591
|
-
|
|
2592
|
-
In a different worktree of the same app:
|
|
2593
|
-
|
|
2594
|
-
```bash
|
|
2595
|
-
rn-iso ios # gets a different sim and Metro port automatically
|
|
2596
|
-
```
|
|
2597
|
-
|
|
2598
|
-
Both run side-by-side, no contention.
|
|
2599
|
-
|
|
2600
|
-
## Commands
|
|
2601
|
-
|
|
2602
|
-
| Command | Purpose |
|
|
2603
|
-
|---|---|
|
|
2604
|
-
| `rn-iso ios [--auto] [--device-type <name>] [--no-install]` | Ensure iOS sim + Metro + build/install |
|
|
2605
|
-
| `rn-iso android [--no-install]` | Same for Android |
|
|
2606
|
-
| `rn-iso start` | Just start Metro, no platform action |
|
|
2607
|
-
| `rn-iso device [--platform ios|android] [--json]` | Print the assigned device target |
|
|
2608
|
-
| `rn-iso status` | Show all projects' state |
|
|
2609
|
-
| `rn-iso release [--platform <p>]` | Unbind device assignment(s) for current project |
|
|
2610
|
-
| `rn-iso shutdown [--platform <p>]` | Release and shut down sims for current project |
|
|
2611
|
-
| `rn-iso prune [--shutdown]` | GC dead entries machine-wide |
|
|
2612
|
-
| `rn-iso logs` | Tail Metro log for current project |
|
|
2613
|
-
| `rn-iso stop` | Kill Metro for current project |
|
|
2614
|
-
|
|
2615
|
-
## How it works
|
|
2616
|
-
|
|
2617
|
-
- **Config** at `~/.rn-iso/config.json`, keyed by absolute project path. Worktrees produce different paths → different entries.
|
|
2618
|
-
- **Port allocation:** assigns 8082, 8083, 8084 etc. Reclaims dead ports on assignment.
|
|
2619
|
-
- **Simulator pool:** prefers reusing your project's existing assignment; falls back to any booted-and-unclaimed sim; prompts to boot a new one if needed (`--auto` skips the prompt).
|
|
2620
|
-
- **No locking:** your sim is yours; other projects' sims are theirs. If you're on tight hardware and want one shared sim with a mutex, use [`react-native-worktree`](https://github.com/aleqsio/react-native-worktree) instead.
|
|
2621
|
-
|
|
2622
|
-
## Requirements
|
|
2623
|
-
|
|
2624
|
-
- macOS (iOS support); Linux/macOS (Android support)
|
|
2625
|
-
- Node 20+
|
|
2626
|
-
- Xcode (iOS), Android SDK + at least one AVD (Android)
|
|
2627
|
-
- Either `expo` in `package.json` (Expo workflow) or `react-native` (bare workflow)
|
|
2628
|
-
|
|
2629
|
-
## License
|
|
2630
|
-
|
|
2631
|
-
MIT
|
|
2632
|
-
```
|
|
2633
|
-
|
|
2634
|
-
- [ ] **Step 2: Commit**
|
|
2635
|
-
|
|
2636
|
-
```bash
|
|
2637
|
-
git add README.md
|
|
2638
|
-
git commit -m "docs: README"
|
|
2639
|
-
```
|
|
2640
|
-
|
|
2641
|
-
---
|
|
2642
|
-
|
|
2643
|
-
## Self-review checklist
|
|
2644
|
-
|
|
2645
|
-
After implementing all tasks:
|
|
2646
|
-
|
|
2647
|
-
- [ ] Run full test suite: `npm test` — all tests pass.
|
|
2648
|
-
- [ ] Manual verification on a real Expo project: `cd ~/some-expo-app && rn-iso ios --auto --no-install` succeeds.
|
|
2649
|
-
- [ ] Manual verification on a worktree: create a worktree of the same app, run `rn-iso ios --auto` in it, confirm a different port and (if available) a different sim are used.
|
|
2650
|
-
- [ ] `rn-iso status` shows both correctly.
|
|
2651
|
-
- [ ] `rn-iso device --json` returns valid JSON with the right port.
|
|
2652
|
-
- [ ] `rn-iso prune` doesn't touch live entries.
|
|
2653
|
-
- [ ] Skill file installs cleanly via the README curl command (test path).
|