rn-iso 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/CLAUDE.md +178 -0
  3. package/README.md +90 -0
  4. package/bin/cli.js +35 -0
  5. package/docs/plans/2026-04-25-rn-iso-implementation.md +2653 -0
  6. package/docs/specs/2026-04-25-rn-iso-design.md +282 -0
  7. package/package.json +20 -0
  8. package/skill/SKILL.md +112 -0
  9. package/src/commands/android.js +112 -0
  10. package/src/commands/device.js +43 -0
  11. package/src/commands/ios.js +210 -0
  12. package/src/commands/logs.js +28 -0
  13. package/src/commands/prune.js +57 -0
  14. package/src/commands/release.js +51 -0
  15. package/src/commands/reserve.js +176 -0
  16. package/src/commands/shutdown.js +41 -0
  17. package/src/commands/start.js +43 -0
  18. package/src/commands/status.js +60 -0
  19. package/src/commands/stop.js +51 -0
  20. package/src/commands/unreserve.js +57 -0
  21. package/src/config.js +221 -0
  22. package/src/exec.js +31 -0
  23. package/src/metro.js +73 -0
  24. package/src/ports.js +50 -0
  25. package/src/project.js +186 -0
  26. package/src/runner.js +136 -0
  27. package/src/sim/android.js +103 -0
  28. package/src/sim/ios.js +128 -0
  29. package/test/config.test.js +208 -0
  30. package/test/exec.test.js +26 -0
  31. package/test/fixtures/sample-bare-project/android/app/build.gradle +6 -0
  32. package/test/fixtures/sample-bare-project/ios/SampleApp.xcodeproj/project.pbxproj +10 -0
  33. package/test/fixtures/sample-bare-project/package.json +4 -0
  34. package/test/fixtures/sample-expo-project/app.json +6 -0
  35. package/test/fixtures/sample-expo-project/package.json +4 -0
  36. package/test/fixtures/sample-expo-project/src/.keep +0 -0
  37. package/test/metro.test.js +34 -0
  38. package/test/ports.test.js +76 -0
  39. package/test/project.test.js +109 -0
  40. package/test/runner.test.js +209 -0
  41. package/test/sim-android.test.js +140 -0
  42. package/test/sim-ios.test.js +168 -0
@@ -0,0 +1,2653 @@
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).