rn-iso 0.1.0 → 0.2.1

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -36
  3. package/bin/cli.js +4 -7
  4. package/package.json +28 -2
  5. package/skill/SKILL.md +41 -43
  6. package/src/commands/android.js +120 -14
  7. package/src/commands/ios.js +95 -33
  8. package/src/commands/release.js +19 -25
  9. package/src/commands/reserve.js +141 -144
  10. package/src/commands/status.js +1 -15
  11. package/src/commands/stop.js +62 -30
  12. package/src/commands/unreserve.js +23 -43
  13. package/src/config.js +14 -91
  14. package/src/labels.js +25 -0
  15. package/src/project.js +25 -7
  16. package/src/sim/android.js +31 -18
  17. package/src/sim/ios.js +7 -1
  18. package/.claude/settings.local.json +0 -7
  19. package/CLAUDE.md +0 -178
  20. package/docs/plans/2026-04-25-rn-iso-implementation.md +0 -2653
  21. package/docs/specs/2026-04-25-rn-iso-design.md +0 -282
  22. package/src/commands/logs.js +0 -28
  23. package/src/commands/prune.js +0 -57
  24. package/src/commands/shutdown.js +0 -41
  25. package/test/config.test.js +0 -208
  26. package/test/exec.test.js +0 -26
  27. package/test/fixtures/sample-bare-project/android/app/build.gradle +0 -6
  28. package/test/fixtures/sample-bare-project/ios/SampleApp.xcodeproj/project.pbxproj +0 -10
  29. package/test/fixtures/sample-bare-project/package.json +0 -4
  30. package/test/fixtures/sample-expo-project/app.json +0 -6
  31. package/test/fixtures/sample-expo-project/package.json +0 -4
  32. package/test/fixtures/sample-expo-project/src/.keep +0 -0
  33. package/test/metro.test.js +0 -34
  34. package/test/ports.test.js +0 -76
  35. package/test/project.test.js +0 -109
  36. package/test/runner.test.js +0 -209
  37. package/test/sim-android.test.js +0 -140
  38. package/test/sim-ios.test.js +0 -168
@@ -1,48 +1,80 @@
1
1
  // src/commands/stop.js
2
2
  import chalk from 'chalk';
3
- import { findProjectRoot } from '../project.js';
4
- import { getProject, setMetro } from '../config.js';
3
+ import { resolveRegisteredProject } from '../project.js';
4
+ import { loadConfig, getProject, setMetro } from '../config.js';
5
5
  import { killMetroByPid } from '../metro.js';
6
6
  import { getExecutor } from '../exec.js';
7
7
 
8
8
  export default function stopCommand(program) {
9
9
  program
10
- .command('stop')
11
- .description('Kill the Metro process for the current project')
12
- .action(() => {
13
- const root = findProjectRoot(process.cwd());
14
- if (!root) {
15
- console.error(chalk.red('Not in a React Native project.'));
10
+ .command('stop [target]')
11
+ .description('Kill Metro. With no arg, stops the current project. Pass a port (e.g. 8083), a project shortcut (label or unique basename), or an absolute path.')
12
+ .action((target) => {
13
+ // Numeric -> kill whatever's on that port.
14
+ if (target && /^\d+$/.test(target)) {
15
+ return killByPort(parseInt(target, 10));
16
+ }
17
+
18
+ const { found, error } = resolveRegisteredProject(target);
19
+ if (!found) {
20
+ console.error(chalk.red(error));
16
21
  process.exit(1);
17
22
  }
18
- const proj = getProject(root);
23
+ const proj = getProject(found);
19
24
  if (!proj?.metroPort) {
20
- console.log(chalk.dim('No Metro port assigned to this project.'));
25
+ console.log(chalk.dim(`No Metro port assigned to ${found}.`));
21
26
  return;
22
27
  }
23
-
24
- // Try the recorded PID first (Metro spawned by `rn-iso start`).
25
- // If not set or stale, look up by port (Metro spawned by the build CLI).
26
- let pid = proj.metroPid;
27
- if (!pid || !killMetroByPid(pid)) {
28
- pid = findPidListeningOnPort(proj.metroPort);
29
- if (!pid) {
30
- console.log(chalk.dim(`No Metro process found on port ${proj.metroPort}.`));
31
- if (proj.metroPid) setMetro(root, proj.metroPort, null);
32
- return;
33
- }
34
- try {
35
- process.kill(pid, 'SIGTERM');
36
- } catch {
37
- console.log(chalk.dim(`Could not kill pid ${pid}.`));
38
- return;
39
- }
40
- }
41
- setMetro(root, proj.metroPort, null);
42
- console.log(chalk.green(`Killed Metro pid ${pid} on port ${proj.metroPort}`));
28
+ killForProject(found, proj);
43
29
  });
44
30
  }
45
31
 
32
+ function killForProject(root, proj) {
33
+ const port = proj.metroPort;
34
+ let pid = proj.metroPid;
35
+ if (!pid || !killMetroByPid(pid)) {
36
+ pid = findPidListeningOnPort(port);
37
+ if (!pid) {
38
+ console.log(chalk.dim(`No Metro process found on port ${port}.`));
39
+ if (proj.metroPid) setMetro(root, port, null);
40
+ return;
41
+ }
42
+ try {
43
+ process.kill(pid, 'SIGTERM');
44
+ } catch {
45
+ console.log(chalk.dim(`Could not kill pid ${pid}.`));
46
+ return;
47
+ }
48
+ }
49
+ setMetro(root, port, null);
50
+ console.log(chalk.green(`Killed Metro pid ${pid} on port ${port} (${root})`));
51
+ }
52
+
53
+ function killByPort(port) {
54
+ const pid = findPidListeningOnPort(port);
55
+ if (!pid) {
56
+ console.log(chalk.dim(`No process listening on port ${port}.`));
57
+ return;
58
+ }
59
+ try {
60
+ process.kill(pid, 'SIGTERM');
61
+ } catch (e) {
62
+ console.log(chalk.dim(`Could not kill pid ${pid}: ${e.message}`));
63
+ return;
64
+ }
65
+ console.log(chalk.green(`Killed pid ${pid} on port ${port}`));
66
+
67
+ // If a project owned this port, clear its recorded pid so `status` reflects.
68
+ const cfg = loadConfig();
69
+ for (const [path, proj] of Object.entries(cfg?.projects || {})) {
70
+ if (proj.metroPort === port) {
71
+ setMetro(path, port, null);
72
+ console.log(chalk.dim(`Cleared metroPid for ${path}`));
73
+ break;
74
+ }
75
+ }
76
+ }
77
+
46
78
  function findPidListeningOnPort(port) {
47
79
  const out = getExecutor().runQuiet(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`);
48
80
  if (!out) return null;
@@ -1,57 +1,37 @@
1
1
  // src/commands/unreserve.js
2
2
  import chalk from 'chalk';
3
- import { findReservations, removeReservation, clearAllReservations } from '../config.js';
3
+ import { findProjectRoot } from '../project.js';
4
+ import { getProject, clearDevice } from '../config.js';
4
5
 
5
6
  export default function unreserveCommand(program) {
6
7
  program
7
- .command('unreserve [arg1] [arg2]')
8
- .description('Release a reservation by UDID/serial or by the --label set when reserving')
9
- .option('--all', 'Remove all reservations')
10
- .option('--platform <platform>', 'Restrict to ios or android')
11
- .action((arg1, arg2, opts) => {
12
- if (opts.all) {
13
- clearAllReservations();
14
- console.log(chalk.green('Cleared all reservations.'));
15
- return;
16
- }
17
-
18
- // Two-arg form (backward compat): `unreserve ios <id-or-label>`.
19
- // Single-arg form: `unreserve <id-or-label>` (search across platforms).
20
- let platform = opts.platform || null;
21
- let target;
22
- if (arg2 !== undefined) {
23
- if (arg1 !== 'ios' && arg1 !== 'android') {
24
- console.error(chalk.red(`Unknown platform: ${arg1}. Use ios or android.`));
25
- process.exit(1);
26
- }
27
- platform = arg1;
28
- target = arg2;
29
- } else {
30
- target = arg1;
8
+ .command('unreserve [platform]')
9
+ .description("Release the current project's lock on its sim/emulator (alias of `release` without shutdown).")
10
+ .action((platform) => {
11
+ const plat = platform || null;
12
+ if (plat && plat !== 'ios' && plat !== 'android') {
13
+ console.error(chalk.red(`Unknown platform: ${plat}. Use ios or android.`));
14
+ process.exit(1);
31
15
  }
32
16
 
33
- if (!target) {
34
- console.error(chalk.red('Usage: rn-iso unreserve <UDID|emulator-PORT|label>'));
35
- console.error(chalk.dim(' rn-iso unreserve <ios|android> <UDID|emulator-PORT|label>'));
36
- console.error(chalk.dim(' rn-iso unreserve --all'));
17
+ const root = findProjectRoot(process.cwd());
18
+ if (!root) {
19
+ console.error(chalk.red('Not in a React Native project (no package.json found).'));
37
20
  process.exit(1);
38
21
  }
39
-
40
- const matches = findReservations(target, platform);
41
- if (matches.length === 0) {
42
- console.log(chalk.dim(
43
- `No reservation matches "${target}"` +
44
- (platform ? ` on ${platform}` : '') +
45
- '.'
46
- ));
22
+ const proj = getProject(root);
23
+ if (!proj) {
24
+ console.log(chalk.dim('No project entry to unlock.'));
47
25
  return;
48
26
  }
49
- for (const m of matches) {
50
- removeReservation(m.platform, m.id);
51
- console.log(chalk.green(
52
- `Released ${m.platform} reservation ${m.id}` +
53
- (m.label ? ` (${m.label})` : '')
54
- ));
27
+ const platforms = plat ? [plat] : ['ios', 'android'];
28
+ for (const p of platforms) {
29
+ if (proj.platforms?.[p]) {
30
+ clearDevice(root, p);
31
+ console.log(chalk.green(`Unlocked ${p} for ${root}.`));
32
+ } else {
33
+ console.log(chalk.dim(`No ${p} lock to release for ${root}.`));
34
+ }
55
35
  }
56
36
  });
57
37
  }
package/src/config.js CHANGED
@@ -28,14 +28,8 @@ export function saveConfig(config) {
28
28
 
29
29
  export function ensureConfig() {
30
30
  const existing = loadConfig();
31
- if (existing) {
32
- if (!existing.reservations) {
33
- existing.reservations = { ios: [], android: [] };
34
- saveConfig(existing);
35
- }
36
- return existing;
37
- }
38
- const fresh = { version: 1, projects: {}, reservations: { ios: [], android: [] } };
31
+ if (existing) return existing;
32
+ const fresh = { version: 1, projects: {} };
39
33
  saveConfig(fresh);
40
34
  return fresh;
41
35
  }
@@ -108,36 +102,31 @@ export function allClaimedDevices() {
108
102
  iosUdids: [],
109
103
  androidAvds: [],
110
104
  androidConsolePorts: [],
111
- // iosClaims: udid -> { source: 'project'|'reservation', label: string }
105
+ // iosClaims: udid -> { label, path }. androidClaims: consolePort ->
106
+ // { label, path, avdName }. `path` is the absolute project path so
107
+ // take-over flows can call clearDevice on the owning project.
112
108
  iosClaims: {},
109
+ androidClaims: {},
113
110
  };
114
111
  if (!cfg) return result;
115
112
  for (const [path, proj] of Object.entries(cfg.projects || {})) {
113
+ const label = path.split('/').pop() || path;
116
114
  const ios = proj.platforms?.ios;
117
115
  if (ios?.deviceUdid) {
118
116
  result.iosUdids.push(ios.deviceUdid);
119
- result.iosClaims[ios.deviceUdid] = {
120
- source: 'project',
121
- label: path.split('/').pop() || path,
122
- };
117
+ result.iosClaims[ios.deviceUdid] = { label, path };
123
118
  }
124
119
  const android = proj.platforms?.android;
125
120
  if (android?.avdName) result.androidAvds.push(android.avdName);
126
- if (typeof android?.consolePort === 'number') result.androidConsolePorts.push(android.consolePort);
127
- }
128
- for (const r of cfg.reservations?.ios || []) {
129
- if (r.udid) {
130
- result.iosUdids.push(r.udid);
131
- result.iosClaims[r.udid] = {
132
- source: 'reservation',
133
- label: r.label || 'reserved',
121
+ if (typeof android?.consolePort === 'number') {
122
+ result.androidConsolePorts.push(android.consolePort);
123
+ result.androidClaims[android.consolePort] = {
124
+ label,
125
+ path,
126
+ avdName: android.avdName,
134
127
  };
135
128
  }
136
129
  }
137
- for (const r of cfg.reservations?.android || []) {
138
- if (r.avdName) result.androidAvds.push(r.avdName);
139
- if (typeof r.consolePort === 'number') result.androidConsolePorts.push(r.consolePort);
140
- }
141
130
  return result;
142
131
  }
143
132
 
@@ -153,69 +142,3 @@ export function getSimUsage() {
153
142
  const cfg = loadConfig();
154
143
  return cfg?.simUsage || { ios: {}, android: {} };
155
144
  }
156
-
157
- export function listReservations() {
158
- const cfg = loadConfig();
159
- return cfg?.reservations || { ios: [], android: [] };
160
- }
161
-
162
- // Find reservations matching either an identifier (UDID for iOS, serial for
163
- // Android) or a label (the --label set via `rn-iso reserve`). Returns
164
- // `[{ platform, id, label }]`. Pass platform to restrict the search.
165
- export function findReservations(idOrLabel, platform = null) {
166
- const r = listReservations();
167
- const matches = [];
168
- if (!platform || platform === 'ios') {
169
- for (const e of r.ios || []) {
170
- if (e.udid === idOrLabel || e.label === idOrLabel) {
171
- matches.push({ platform: 'ios', id: e.udid, label: e.label });
172
- }
173
- }
174
- }
175
- if (!platform || platform === 'android') {
176
- for (const e of r.android || []) {
177
- if (e.serial === idOrLabel || e.label === idOrLabel) {
178
- matches.push({ platform: 'android', id: e.serial, label: e.label });
179
- }
180
- }
181
- }
182
- return matches;
183
- }
184
-
185
- export function addReservation(platform, fields) {
186
- if (platform !== 'ios' && platform !== 'android') {
187
- throw new Error(`Unknown platform: ${platform}`);
188
- }
189
- const cfg = ensureConfig();
190
- cfg.reservations = cfg.reservations || { ios: [], android: [] };
191
- const list = cfg.reservations[platform];
192
- const key = platform === 'ios' ? 'udid' : 'serial';
193
- const existing = list.find(r => r[key] === fields[key]);
194
- if (existing) {
195
- Object.assign(existing, fields);
196
- } else {
197
- list.push(fields);
198
- }
199
- saveConfig(cfg);
200
- return fields;
201
- }
202
-
203
- export function removeReservation(platform, identifier) {
204
- if (platform !== 'ios' && platform !== 'android') {
205
- throw new Error(`Unknown platform: ${platform}`);
206
- }
207
- const cfg = loadConfig();
208
- if (!cfg?.reservations?.[platform]) return false;
209
- const key = platform === 'ios' ? 'udid' : 'serial';
210
- const before = cfg.reservations[platform].length;
211
- cfg.reservations[platform] = cfg.reservations[platform].filter(r => r[key] !== identifier);
212
- saveConfig(cfg);
213
- return cfg.reservations[platform].length < before;
214
- }
215
-
216
- export function clearAllReservations() {
217
- const cfg = loadConfig();
218
- if (!cfg) return;
219
- cfg.reservations = { ios: [], android: [] };
220
- saveConfig(cfg);
221
- }
package/src/labels.js ADDED
@@ -0,0 +1,25 @@
1
+ import prompts from 'prompts';
2
+
3
+ // Decide what label to store on a project entry.
4
+ //
5
+ // Priority:
6
+ // 1. --label flag (explicit override)
7
+ // 2. existing label on the project (don't re-prompt)
8
+ // 3. interactive prompt with the basename as default
9
+ // 4. null (non-interactive and no prior label) -> projectShortcut falls
10
+ // back to the basename automatically.
11
+ export async function resolveLabel({ root, existingProject, optsLabel }) {
12
+ if (optsLabel) return optsLabel;
13
+ if (existingProject?.label) return existingProject.label;
14
+ if (!process.stdin.isTTY) return null;
15
+
16
+ const basename = root.split('/').pop() || root;
17
+ const answer = await prompts({
18
+ type: 'text',
19
+ name: 'label',
20
+ message: 'Project label (shortcut for stop / release):',
21
+ initial: basename,
22
+ });
23
+ if (!answer.label) return null;
24
+ return answer.label;
25
+ }
package/src/project.js CHANGED
@@ -2,13 +2,21 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from 'fs';
2
2
  import { join, dirname, resolve } from 'path';
3
3
  import { loadConfig } from './config.js';
4
4
 
5
- // Resolve a project from one of two forms:
6
- // - undefined / null -> walk up from cwd (current behavior)
7
- // - absolute or relative path that matches a registered project key
5
+ // A project's "shortcut": its `label` field if set, else the path basename.
6
+ // This is the user-facing handle for `stop`, `release`, etc.
7
+ export function projectShortcut(path, proj) {
8
+ if (proj?.label) return proj.label;
9
+ return path.split('/').pop() || path;
10
+ }
11
+
12
+ // Resolve a project from one of three forms:
13
+ // - undefined / null -> walk up from cwd
14
+ // - absolute / relative path matching a registered project key
15
+ // - a shortcut (label or path basename) that uniquely identifies a project
8
16
  //
9
- // Basename / fuzzy matching is intentionally NOT supported -- collisions
10
- // across worktrees with the same dir name make it ambiguous. Use full paths
11
- // or the reservation label (`--label` on `rn-iso reserve`) instead.
17
+ // Basename matching errors out on collision (multiple projects share the
18
+ // basename) set a `--label` on one of them via `rn-iso reserve` / `ios` /
19
+ // `android` to disambiguate.
12
20
  //
13
21
  // Returns { found, error }.
14
22
  export function resolveRegisteredProject(arg) {
@@ -29,9 +37,19 @@ export function resolveRegisteredProject(arg) {
29
37
  if (projects[abs]) return { found: abs };
30
38
  if (projects[arg]) return { found: arg };
31
39
 
40
+ // Shortcut match (label or basename).
41
+ const matches = Object.keys(projects).filter(p => projectShortcut(p, projects[p]) === arg);
42
+ if (matches.length === 1) return { found: matches[0] };
43
+ if (matches.length > 1) {
44
+ return {
45
+ found: null,
46
+ error: `Multiple projects share the shortcut "${arg}": ${matches.join(', ')}. Pass the absolute path or set a unique --label.`,
47
+ };
48
+ }
49
+
32
50
  return {
33
51
  found: null,
34
- error: `No registered project at "${arg}". Pass an absolute path; see \`rn-iso status\` for the list.`,
52
+ error: `No registered project matches "${arg}". See \`rn-iso status\` for the list.`,
35
53
  };
36
54
  }
37
55
 
@@ -37,35 +37,48 @@ export function nextConsolePort(claimedPorts) {
37
37
 
38
38
  export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedAvds, claimedConsolePorts }) {
39
39
  const avds = listAvds();
40
+ if (avds.length === 0) return { kind: 'noAvd' };
41
+
40
42
  const adbDevices = listAdbDevices();
41
- const runningPorts = new Set(adbDevices.emulators.map(e => e.consolePort));
43
+ // Resolve which AVD each running emulator is. Emulators that don't respond
44
+ // to `adb emu avd name` are dropped from the running map.
45
+ const runningByAvd = {};
46
+ for (const e of adbDevices.emulators) {
47
+ const avdName = getAvdNameForSerial(e.serial);
48
+ if (avdName) runningByAvd[avdName] = e.consolePort;
49
+ }
50
+
51
+ const buildCandidate = (avdName) => ({
52
+ avdName,
53
+ isRunning: avdName in runningByAvd,
54
+ consolePort: runningByAvd[avdName] ?? null,
55
+ });
42
56
 
43
57
  if (existingAvd && avds.includes(existingAvd)) {
44
- const port = existingConsolePort ?? nextConsolePort(claimedConsolePorts);
58
+ const runningPort = runningByAvd[existingAvd];
45
59
  return {
46
60
  kind: 'reuse',
47
61
  avdName: existingAvd,
48
- consolePort: port,
49
- isRunning: runningPorts.has(port),
62
+ consolePort: runningPort ?? existingConsolePort ?? nextConsolePort(claimedConsolePorts),
63
+ isRunning: runningPort != null,
50
64
  };
51
65
  }
52
66
 
53
- if (avds.length === 0) {
54
- return { kind: 'noAvd' };
55
- }
56
-
57
67
  const claimedAvdSet = new Set(claimedAvds);
58
- const candidate = avds.find(a => !claimedAvdSet.has(a));
59
- if (!candidate) {
60
- return { kind: 'noAvd' };
68
+ const all = avds.map(buildCandidate);
69
+ const unclaimed = all.filter(c => !claimedAvdSet.has(c.avdName));
70
+
71
+ if (unclaimed.length === 0) {
72
+ return { kind: 'allClaimed', candidates: sortAndroidCandidates(all) };
61
73
  }
62
- const consolePort = nextConsolePort(claimedConsolePorts);
63
- return {
64
- kind: 'allocate',
65
- avdName: candidate,
66
- consolePort,
67
- isRunning: runningPorts.has(consolePort),
68
- };
74
+ return { kind: 'allocate', candidates: sortAndroidCandidates(unclaimed) };
75
+ }
76
+
77
+ export function sortAndroidCandidates(list) {
78
+ return [...list].sort((a, b) => {
79
+ if (a.isRunning !== b.isRunning) return a.isRunning ? -1 : 1;
80
+ return a.avdName.localeCompare(b.avdName);
81
+ });
69
82
  }
70
83
 
71
84
  export function bootAndroidEmulator(avdName, consolePort) {
package/src/sim/ios.js CHANGED
@@ -66,8 +66,14 @@ export function selectIosDevice({ existingUdid, claimedUdids, usage = {} }) {
66
66
  }
67
67
  }
68
68
 
69
+ if (sims.length === 0) return { kind: 'noSims' };
70
+
69
71
  const unclaimed = sims.filter(s => !claimed.has(s.udid));
70
- if (unclaimed.length === 0) return { kind: 'needsBoot' };
72
+ if (unclaimed.length === 0) {
73
+ // Sims exist but every one is claimed by another project or a
74
+ // reservation. The picker can offer to steal one; the caller decides.
75
+ return { kind: 'allClaimed', candidates: sortSims(sims, usage) };
76
+ }
71
77
 
72
78
  return { kind: 'allocate', candidates: sortSims(unclaimed, usage) };
73
79
  }
@@ -1,7 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(git -C /Users/janicduplessis/Developer/rn-iso remote -v)"
5
- ]
6
- }
7
- }
package/CLAUDE.md DELETED
@@ -1,178 +0,0 @@
1
- # rn-iso — agent guide
2
-
3
- Quick orientation for AI assistants working in this repo.
4
-
5
- ## What this is
6
-
7
- A Node.js CLI that gives each React Native / Expo project (or git worktree)
8
- its own Metro server and dedicated simulator/emulator, so multiple agents can
9
- work on different projects in parallel without device or port collisions.
10
-
11
- State lives in `~/.rn-iso/config.json`, keyed by absolute project path. The
12
- `RN_ISO_HOME` env var redirects this for tests.
13
-
14
- ## Architecture conventions
15
-
16
- - **ESM only.** `"type": "module"`, no transpiler, Node 20+ directly. No
17
- CommonJS, no `require()`.
18
- - **Single exec wrapper.** All `child_process` calls go through
19
- `src/exec.js` (`getExecutor()`). Tests inject a mock via `setExecutor()`.
20
- Anywhere outside `exec.js` that imports `child_process` directly is a bug.
21
- - **Pure parsing separate from invocation.** Functions like `parseSimctlList`,
22
- `parseAdbDevices`, `selectIosDevice`, `sortSims` are pure and unit-tested;
23
- the I/O wrappers around them are thin.
24
- - **ASCII in source files.** No em dashes, smart quotes, or check marks in
25
- `src/`, `bin/`, `test/`. Markdown files (README, SKILL, this file) may use
26
- them. The hooks have flagged this before.
27
-
28
- ## File layout
29
-
30
- ```
31
- bin/cli.js # commander entry, registers each command module
32
- src/
33
- exec.js # mockable child_process wrapper
34
- config.js # config CRUD, reservations, sim-usage tracking
35
- project.js # project root walk, bundle-id detection (incl. native fallbacks)
36
- ports.js # Metro port allocation + reclamation
37
- runner.js # script-vs-CLI dispatch, package-manager detection (walks up for monorepos)
38
- metro.js # detached Metro spawn, PID + log lifecycle
39
- sim/
40
- ios.js # simctl wrappers, sim selection, sortSims, parseRuntimeVersion
41
- android.js # adb/emulator wrappers, AVD selection
42
- commands/
43
- ios.js android.js # the main user-facing commands
44
- start.js stop.js logs.js
45
- status.js
46
- device.js # `rn-iso device --json` -> agent-device target
47
- release.js shutdown.js prune.js
48
- reserve.js unreserve.js
49
- test/
50
- *.test.js # `node --test` (no framework)
51
- skill/SKILL.md # the agent-facing skill
52
- ```
53
-
54
- ## Particularities to remember
55
-
56
- ### 1. Update `skill/SKILL.md` whenever user-facing behavior changes
57
-
58
- The skill is what installed AI agents read to learn how to use the CLI. When
59
- you add a command, change a flag, change picker UX, or alter defaults — open
60
- `skill/SKILL.md` and update the relevant section in the same change. Quick
61
- checklist:
62
-
63
- - New command? Add it under "Other useful commands" or its own section if
64
- meaty (like `reserve`).
65
- - New / changed flag on `ios` or `android`? Update "Core workflow" and
66
- "Critical rules" if the flag matters for non-interactive agent use.
67
- - Behavior change (e.g., picker now does X)? Update both the
68
- description and the "When things go wrong" section.
69
-
70
- The skill is shipped to users via the curl line in the README; staleness
71
- breaks agent guidance.
72
-
73
- ### 2. Don't auto-create simulators
74
-
75
- `selectIosDevice` returns `needsBoot` only when no unclaimed sim exists at
76
- all. `commands/ios.js` then errors unless `--device-type` is passed. We do
77
- NOT prompt and create on the user's behalf — that was the original UX and
78
- was removed because it accumulated junk sims. The picker only chooses among
79
- EXISTING sims (booted or shutdown). When you change device-selection logic,
80
- preserve this invariant.
81
-
82
- ### 3. The post-install verification step is intentionally absent
83
-
84
- Earlier versions ran `xcrun simctl install/launch` after the build CLI to
85
- work around a wrong-sim bug in `@expo/cli` (since fixed in 54.0.24). That
86
- step caused double-launches and was removed. If you find yourself wanting
87
- to add it back, the upstream bug is the right place to fix things —
88
- `patch-package` for stuck users, not workaround code in `commands/ios.js`.
89
-
90
- ### 3b. `rn-iso ios` / `android` do NOT spawn Metro
91
-
92
- The build CLI (`expo run:ios` / `react-native run-ios`) starts Metro
93
- itself on the `--port` we pass. We used to also pre-spawn a detached
94
- Metro before the build, which led to two Metros on the same port.
95
- Removed.
96
-
97
- `rn-iso start` is still around for the explicit "I just want Metro" case
98
- — it spawns Metro detached and tracks the PID + log file. The build
99
- commands rely on the build CLI's Metro; `rn-iso stop` looks up the PID
100
- by port (via `lsof`) so it works regardless of who started Metro.
101
-
102
- ### 4. Reservations are first-class claims
103
-
104
- `allClaimedDevices()` returns BOTH project-claimed AND reservation-claimed
105
- devices. If you add a new claim source (e.g., a new section in config.json),
106
- extend `allClaimedDevices` AND `iosClaims` so the picker greys it out with a
107
- useful label. Don't filter at any one call site — keep the policy in
108
- `config.js`.
109
-
110
- ### 5. Package-manager / script detection
111
-
112
- `runner.js` prefers the project's `ios` / `android` script over a direct
113
- `expo run:ios` / `react-native run-ios` invocation. Reasons: respects user
114
- flags, picks the right CLI, works with non-standard setups (rainbow has
115
- `expo` in deps but uses `react-native run-ios`).
116
-
117
- `detectScriptCli` regex-matches the script body to decide flag names
118
- (`--device <UDID>` for Expo, `--udid <UDID>` for RN). If you ever need a
119
- new flag, update both the script-path branch and the direct fallback.
120
-
121
- `detectPackageManager` walks up from the project root looking for a
122
- lockfile (monorepo support). Don't single-directory-check.
123
-
124
- ### 6. `RN_ISO_HOME` is the test redirect
125
-
126
- All config + log paths derive from `getConfigDir()`, which respects
127
- `RN_ISO_HOME`. Every config-touching test does:
128
-
129
- ```js
130
- beforeEach(() => {
131
- tmpHome = mkdtempSync(join(tmpdir(), 'rn-iso-test-'));
132
- process.env.RN_ISO_HOME = tmpHome;
133
- });
134
- afterEach(() => {
135
- rmSync(tmpHome, { recursive: true, force: true });
136
- delete process.env.RN_ISO_HOME;
137
- });
138
- ```
139
-
140
- If you add new state-touching code, follow this pattern.
141
-
142
- ### 7. `findProjectRoot` uses `realpath`
143
-
144
- So symlinked worktrees collapse to the same canonical key as the
145
- non-symlinked path. Don't add code that compares paths without
146
- canonicalizing first.
147
-
148
- ## Local development
149
-
150
- ```bash
151
- npm install # one-time
152
- npm test # node --test test/*.test.js
153
- npm link # symlink rn-iso onto your PATH for live testing
154
- ```
155
-
156
- After `npm link`, edits to `src/` are picked up immediately by the linked
157
- `rn-iso` command.
158
-
159
- ## Commit conventions
160
-
161
- - GPG signing is enabled globally — commits sign automatically. Don't pass
162
- `--no-gpg-sign`. If you need to re-sign an existing commit (e.g.,
163
- someone forgot signing), `git commit --amend --no-edit -S` works.
164
- - Conventional-style prefixes are used (`feat:`, `fix:`, `docs:`,
165
- `chore:`, `revert:`). Keep titles under ~70 chars; details in the body.
166
- - One commit per logical change. The post-install removal and the
167
- script-based runner came in as separate commits even though they shipped
168
- in the same session.
169
-
170
- ## Things explicitly out of scope (for now)
171
-
172
- - Locking / mutex around device usage. The whole premise is dedicated sims.
173
- - Auto-shutdown of sims after N hours of inactivity.
174
- - Cross-platform support beyond macOS (iOS) + macOS/Linux (Android).
175
- - Multi-app projects (one repo, multiple Expo apps via `--variant`).
176
- - A daemon or TUI dashboard.
177
-
178
- If a request edges into these, raise it instead of building it.