rn-iso 0.4.1 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,6 +55,7 @@ All commands below take the same `npx rn-iso` prefix.
55
55
  | `reserve [ios\|android]` | Lock a manually-started sim/emulator to the current project (no build) |
56
56
  | `unreserve [ios\|android]` | Drop the current project's lock without shutting the sim down |
57
57
  | `release [<port>\|<shortcut>\|<path>] [--platform <p>] [--shutdown]` | Free a project's assignment. Target can be a Metro port (`8083`), a shortcut (label or unique basename), or an absolute path. `--shutdown` also stops the sim. |
58
+ | `shutdown [<shortcut>\|<path>] [-y] [--keep-sims]` | Kill Metro, shut down sims/emulators, and clear device assignments. No arg = every registered project (end-of-day reset). Pass a shortcut or path to scope to one. |
58
59
  | `config [<key> [<value>]] [--unset] [--project <target>]` | Get / set a per-project setting (`packageManager`, `ios.script`, `android.script`). |
59
60
 
60
61
  ## How it works
package/bin/cli.js CHANGED
@@ -10,6 +10,7 @@ import statusCommand from '../src/commands/status.js';
10
10
  import releaseCommand from '../src/commands/release.js';
11
11
  import reserveCommand from '../src/commands/reserve.js';
12
12
  import unreserveCommand from '../src/commands/unreserve.js';
13
+ import shutdownCommand from '../src/commands/shutdown.js';
13
14
  import configCommand from '../src/commands/config.js';
14
15
 
15
16
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
@@ -29,6 +30,7 @@ statusCommand(program);
29
30
  releaseCommand(program);
30
31
  reserveCommand(program);
31
32
  unreserveCommand(program);
33
+ shutdownCommand(program);
32
34
  configCommand(program);
33
35
 
34
36
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rn-iso",
3
- "version": "0.4.1",
3
+ "version": "0.4.5",
4
4
  "description": "Isolated React Native dev environments per project/worktree",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -23,14 +23,14 @@ From the project root (or any subdirectory):
23
23
  ```json
24
24
  {"platform":"ios","udid":"ABC-...","metroPort":8083}
25
25
  ```
26
- Use the UDID for `agent-device` / `xcrun simctl` / `idb`. For Android, the `serial` field gives you `emulator-<port>` to use with `adb -s`.
26
+ Use the UDID for `agent-device` / `xcrun simctl` / `idb`. For Android, the `serial` field gives you `emulator-<port>` (or the hardware serial for a physical device) to use with `adb -s`. The Android JSON payload also includes `kind: "emulator" | "physical"`.
27
27
 
28
28
  3. **Interact with the device** — pass the UDID/serial to your UI tools. Never call `simctl <verb>` without `<UDID>` — `booted` could be the wrong sim.
29
29
 
30
30
  ## CRITICAL rules
31
31
 
32
32
  - **Pass `--auto` for non-interactive use** of `ios` or `android`. Without it, the command will prompt with an arrow-key picker if multiple unclaimed sims/AVDs exist. `--auto` is also implied automatically when stdin isn't a TTY (e.g., when an agent pipes the command), so under most agent harnesses you don't have to remember the flag — but passing it explicitly is harmless and clearer.
33
- - **Forward extra flags to the build CLI with `--`.** `npx rn-iso ios -- --variant=release` (or `android -- --mode=diaRelease`) appends those flags to the underlying `react-native run-*` / `expo run:*` invocation. Useful for release-mode builds, custom terminals, etc. Last-wins semantics, so extras can override defaults rn-iso set earlier in the command.
33
+ - **Forward extra flags to the build CLI with `--`.** `npx rn-iso ios -- --variant=release` (or `android -- --mode=diaRelease`) appends those flags to the underlying `react-native run-*` / `expo run:*` invocation. Useful for release-mode builds, custom terminals, etc. Last-wins semantics, so extras can override defaults rn-iso set earlier in the command. `start` accepts the same `--` extras and forwards them to `expo start` / `react-native start` — e.g. `npx rn-iso start -- --reset-cache`. If Metro is already running, extras are not applied (run `rn-iso stop` first and re-run).
34
34
  - **`--auto` will NOT take over a claimed sim/AVD.** If every device is claimed by other rn-iso projects, `--auto` errors. To take one over, run the command interactively (no `--auto`, with a real TTY) and confirm at the prompt — only do this if the user explicitly asks.
35
35
  - **Always use `npx rn-iso device` to discover your target.** Never assume `booted` is your sim — another project's simulator might be booted too.
36
36
  - **Always pass the UDID/serial explicitly** to `xcrun simctl` and `adb -s`. Examples:
@@ -38,7 +38,7 @@ From the project root (or any subdirectory):
38
38
  - `adb -s emulator-5556 shell input tap 100 200`
39
39
  - **Don't call `release` or `release --shutdown`** unless the user explicitly asks. Other agents may be using neighboring sims; keep yours up so the user can come back to it.
40
40
  - **Don't manually start Metro on a different port.** `npx rn-iso start` (or `npx rn-iso ios/android`) already handles port assignment.
41
- - **rn-iso never auto-creates simulators.** It reuses existing unclaimed sims (booted or shutdown). If none are available, it errors. To create a new one explicitly, pass `--device-type "iPhone 17 Pro" [--runtime 26.2]`.
41
+ - **rn-iso never auto-creates simulators.** It reuses existing unclaimed sims (booted or shutdown) and, on Android, also surfaces any physical device adb can see. If nothing is available, it errors. To create a new iOS sim explicitly, pass `--device-type "iPhone 17 Pro" [--runtime 26.2]`.
42
42
 
43
43
  ## Typical agent workflow
44
44
 
@@ -81,9 +81,10 @@ Reserve binds the sim to the current project the same way `ios` does, but skips
81
81
  ## Other useful commands
82
82
 
83
83
  - `npx rn-iso status` — show all projects, their assignments, and Metro state.
84
- - `npx rn-iso start` — start Metro detached on the project's assigned port WITHOUT building/installing. Useful to keep Metro alive across builds.
84
+ - `npx rn-iso start [-- <extras...>]` — start Metro detached on the project's assigned port WITHOUT building/installing. Useful to keep Metro alive across builds. Extras after `--` are forwarded to `expo start` / `react-native start` (e.g. `--reset-cache`).
85
85
  - `npx rn-iso stop [<port>|<shortcut>|<path>]` — kill Metro. No arg = current project. Passing a port (e.g. `8083`) kills whatever is on it; a project shortcut (label or unique basename) or absolute path targets that project. Finds the process by port, so it works whether Metro was started by `npx rn-iso start` or by the build CLI.
86
86
  - `npx rn-iso release [<port>|<shortcut>|<path>] [--platform <p>] [--shutdown]` — free a project's sim assignment. Defaults to the current project. Target can also be a Metro port (`8083`) or a shortcut (label / unique basename). `--shutdown` also stops the sim/emulator.
87
+ - `npx rn-iso shutdown [<shortcut>|<path>] [-y] [--keep-sims]` — kill Metro, shut down sims/emulators, and clear device assignments. With no arg, scopes to **every** registered project (end-of-day reset); pass a project shortcut (label or unique basename) or absolute path to scope to one. Note this does NOT default to the current project (deliberate — `shutdown` is the explicit "tear it all down" command). Prompts unless `-y` / non-TTY; `--keep-sims` only kills Metro and clears assignments without touching the sims. Project entries themselves stay registered, so `metroPort` allocations and labels survive.
87
88
  - `npx rn-iso config [<key> [<value>]] [--unset] [--project <target>]` — persist per-project settings. Allowed keys: `packageManager` (npm|yarn|pnpm|bun), `ios.script`, `android.script`. Resolution order on `ios`/`android`: CLI flag > stored setting > inferred default. Useful when a project's build script is named differently (`dev:ios` instead of `ios`) or when a different package manager is used than the lockfile suggests.
88
89
 
89
90
  ### Project shortcuts (--label)
@@ -103,7 +104,7 @@ When the iOS picker fires, sims are sorted by:
103
104
  3. Usage count (most-used floats up; tracked per UDID across all projects)
104
105
  4. Name (alphabetical, stable tiebreak)
105
106
 
106
- When the Android picker fires, AVDs are sorted by running state (running emulators first), then alphabetically.
107
+ When the Android picker fires, candidates include both AVDs on disk and physical devices currently visible to `adb`. They are sorted by running state (running emulators and connected physical devices first), then physical above AVDs within the same running group, then alphabetically. Physical devices show with a `[physical]` tag. Once selected, a physical device is claimed by serial just like an AVD; `release` clears the claim but never shuts the device down.
107
108
 
108
109
  Sims/AVDs claimed by other rn-iso projects show in yellow with a `[claimed by ...]` tag. They're selectable but require a confirm prompt before being taken over.
109
110
 
@@ -12,6 +12,7 @@ import {
12
12
  waitForBoot,
13
13
  adbReverse,
14
14
  nextConsolePort,
15
+ listAdbDevices,
15
16
  } from '../sim/android.js';
16
17
  import { buildAndroidCommand, detectPackageManager } from '../runner.js';
17
18
  import { getExecutor } from '../exec.js';
@@ -65,79 +66,145 @@ export default function androidCommand(program) {
65
66
  const claimed = allClaimedDevices();
66
67
  const myAvd = proj.platforms?.android?.avdName || null;
67
68
  const myPort = proj.platforms?.android?.consolePort || null;
69
+ const mySerial = proj.platforms?.android?.serial || null;
68
70
  const claimedAvds = claimed.androidAvds.filter(a => a !== myAvd);
69
71
  const claimedPorts = claimed.androidConsolePorts.filter(p => p !== myPort);
72
+ const claimedSerials = claimed.androidPhysicalSerials.filter(s => s !== mySerial);
70
73
 
71
74
  const selection = selectAndroidDevice({
72
75
  existingAvd: myAvd,
76
+ existingSerial: mySerial,
73
77
  existingConsolePort: myPort,
74
78
  claimedAvds,
79
+ claimedSerials,
75
80
  claimedConsolePorts: claimedPorts,
76
81
  });
77
82
 
78
- let avdName, consolePort, isRunning;
83
+ let avdName = null;
84
+ let consolePort = null;
85
+ let serial = null;
86
+ let isRunning = false;
87
+ let isPhysical = false;
79
88
  if (selection.kind === 'reuse') {
80
- ({ avdName, consolePort, isRunning } = selection);
81
- if (isRunning) {
82
- console.log(chalk.dim(`Reusing running ${avdName} (emulator-${consolePort})`));
89
+ if (selection.deviceKind === 'physical') {
90
+ isPhysical = true;
91
+ serial = selection.serial;
92
+ isRunning = true;
93
+ console.log(chalk.dim(`Reusing physical device ${serial}`));
83
94
  } else {
84
- console.log(chalk.dim(`Booting assigned ${avdName} (emulator-${consolePort})...`));
95
+ ({ avdName, consolePort, isRunning } = selection);
96
+ if (isRunning) {
97
+ console.log(chalk.dim(`Reusing running ${avdName} (emulator-${consolePort})`));
98
+ } else {
99
+ console.log(chalk.dim(`Booting assigned ${avdName} (emulator-${consolePort})...`));
100
+ }
85
101
  }
86
102
  } else if (selection.kind === 'allocate') {
87
103
  const picked = (selection.candidates.length === 1 || auto)
88
104
  ? { c: selection.candidates[0], prevClaim: null }
89
- : await pickAvd({
105
+ : await pickAndroidDevice({
90
106
  candidates: selection.candidates,
91
107
  androidClaimsByAvd: claimed.androidClaimsByAvd,
108
+ androidPhysicalClaimsBySerial: claimed.androidPhysicalClaimsBySerial,
92
109
  });
93
110
  await releasePriorClaim(picked.prevClaim);
94
- ({ avdName, isRunning, consolePort } = picked.c);
95
- if (!isRunning) {
96
- consolePort = nextConsolePort(claimedPorts);
111
+ if (picked.c.kind === 'physical') {
112
+ isPhysical = true;
113
+ serial = picked.c.serial;
114
+ isRunning = true;
115
+ console.log(chalk.green(`Picked physical device ${serial}`));
116
+ } else {
117
+ ({ avdName, isRunning, consolePort } = picked.c);
118
+ if (!isRunning) {
119
+ consolePort = nextConsolePort(claimedPorts);
120
+ }
121
+ console.log(isRunning
122
+ ? chalk.green(`Picked ${avdName} (emulator-${consolePort}, running)`)
123
+ : chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
97
124
  }
98
- console.log(isRunning
99
- ? chalk.green(`Picked ${avdName} (emulator-${consolePort}, running)`)
100
- : chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
101
125
  } else if (selection.kind === 'allClaimed') {
102
126
  if (auto) {
103
- console.error(chalk.red('All Android AVDs are claimed by other rn-iso projects.'));
127
+ console.error(chalk.red('All Android devices are claimed by other rn-iso projects.'));
104
128
  console.error(chalk.dim('Re-run without --auto to confirm taking one over, or create a new AVD via Android Studio.'));
105
129
  process.exit(1);
106
130
  }
107
- const picked = await pickAvd({
131
+ const picked = await pickAndroidDevice({
108
132
  candidates: selection.candidates,
109
133
  androidClaimsByAvd: claimed.androidClaimsByAvd,
134
+ androidPhysicalClaimsBySerial: claimed.androidPhysicalClaimsBySerial,
110
135
  allClaimed: true,
111
136
  });
112
137
  await releasePriorClaim(picked.prevClaim);
113
- ({ avdName, isRunning, consolePort } = picked.c);
114
- if (!isRunning) {
115
- // Prior owner's port is freed by releasePriorClaim, but compute fresh.
116
- const fresh = allClaimedDevices().androidConsolePorts.filter(p => p !== myPort);
117
- consolePort = nextConsolePort(fresh);
138
+ if (picked.c.kind === 'physical') {
139
+ isPhysical = true;
140
+ serial = picked.c.serial;
141
+ isRunning = true;
142
+ console.log(chalk.green(`Took over physical device ${serial}`));
143
+ } else {
144
+ ({ avdName, isRunning, consolePort } = picked.c);
145
+ if (!isRunning) {
146
+ // Prior owner's port is freed by releasePriorClaim, but compute fresh.
147
+ const fresh = allClaimedDevices().androidConsolePorts.filter(p => p !== myPort);
148
+ consolePort = nextConsolePort(fresh);
149
+ }
150
+ console.log(isRunning
151
+ ? chalk.green(`Took over ${avdName} (emulator-${consolePort}, running)`)
152
+ : chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
118
153
  }
119
- console.log(isRunning
120
- ? chalk.green(`Took over ${avdName} (emulator-${consolePort}, running)`)
121
- : chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
122
154
  } else {
123
155
  console.error(chalk.red(
124
- 'No AVDs available. Create one via Android Studio (Tools -> Device Manager).'
156
+ 'No AVDs or physical devices available. Create an AVD via Android Studio (Tools -> Device Manager), or plug in a device with USB debugging enabled.'
125
157
  ));
126
158
  process.exit(1);
127
159
  }
128
160
 
129
- const serial = `emulator-${consolePort}`;
130
- if (!isRunning) {
161
+ if (!isPhysical) serial = `emulator-${consolePort}`;
162
+ if (!isPhysical && !isRunning) {
163
+ // Pre-spawn sanity check: an emulator may already be attached on
164
+ // this console port but in `unauthorized` / `offline` state, in
165
+ // which case enumerateAndroidCandidates marked isRunning=false.
166
+ // Spawning a second emulator on the same port would silently
167
+ // collide and the boot wait would poll forever.
168
+ const adb = listAdbDevices();
169
+ const stuck = adb.unhealthy.find(u => u.consolePort === consolePort);
170
+ if (stuck) {
171
+ console.error(chalk.red(
172
+ `adb sees ${stuck.serial} but its status is "${stuck.status}". rn-iso can't drive it in this state.`
173
+ ));
174
+ if (stuck.status === 'unauthorized') {
175
+ console.error(chalk.dim('Likely cause: the emulator and your ~/.android/adbkey.pub are out of sync.'));
176
+ console.error(chalk.dim('Try: `adb kill-server && adb start-server` first; if it stays unauthorized,'));
177
+ console.error(chalk.dim('cold-boot the AVD from Android Studio Device Manager.'));
178
+ } else {
179
+ console.error(chalk.dim('Try: `adb kill-server && adb start-server`, then re-run.'));
180
+ }
181
+ process.exit(1);
182
+ }
131
183
  bootAndroidEmulator(avdName, consolePort);
132
184
  console.log(chalk.dim('Waiting for boot to complete (this can take 10-30s)...'));
133
- const ok = await waitForBoot(serial, 120000);
134
- if (!ok) {
185
+ const result = await waitForBoot(serial, 120000);
186
+ if (!result.ok) {
135
187
  console.error(chalk.red(`Emulator ${serial} did not finish booting within 2 minutes.`));
188
+ const d = result.diagnostic;
189
+ console.error(chalk.dim('adb devices ->'));
190
+ console.error(chalk.dim((d.devices || '<no output>').split('\n').map(l => ' ' + l).join('\n')));
191
+ console.error(chalk.dim(
192
+ `getprop sys.boot_completed=${d.sysBoot || '<empty>'} ` +
193
+ `dev.bootcomplete=${d.devBoot || '<empty>'} ` +
194
+ `init.svc.bootanim=${d.bootAnim || '<empty>'}`
195
+ ));
196
+ if (!d.devices.includes(serial)) {
197
+ console.error(chalk.dim(`Hint: ${serial} is not in adb's device list. Try \`adb kill-server && adb start-server\`.`));
198
+ }
136
199
  process.exit(1);
137
200
  }
138
201
  }
139
202
 
140
- setDevice(root, 'android', { avdName, consolePort });
203
+ if (isPhysical) {
204
+ setDevice(root, 'android', { serial });
205
+ } else {
206
+ setDevice(root, 'android', { avdName, consolePort });
207
+ }
141
208
 
142
209
  adbReverse(serial, proj.metroPort);
143
210
  console.log(chalk.dim(`adb reverse tcp:${proj.metroPort} configured for ${serial}`));
@@ -168,39 +235,46 @@ export default function androidCommand(program) {
168
235
  });
169
236
  }
170
237
 
171
- console.log(chalk.green(`\nAndroid ready on ${avdName} (${serial}), Metro port ${proj.metroPort}`));
238
+ const readyLabel = isPhysical ? `physical device ${serial}` : `${avdName} (${serial})`;
239
+ console.log(chalk.green(`\nAndroid ready on ${readyLabel}, Metro port ${proj.metroPort}`));
172
240
  });
173
241
  }
174
242
 
175
- async function pickAvd({ candidates, androidClaimsByAvd = {}, allClaimed = false }) {
176
- // Show every AVD on disk (parallel to the iOS picker), so the user can
177
- // see what's claimed and optionally take it over. `candidates` is the
178
- // unclaimed set passed in by selectAndroidDevice; AVDs outside it are
179
- // claimed and will require a confirm prompt on selection.
180
- const allAvds = enumerateAndroidCandidates();
181
- const candidateAvds = new Set(candidates.map(c => c.avdName));
182
- const sorted = sortAndroidCandidates(allAvds);
243
+ async function pickAndroidDevice({ candidates, androidClaimsByAvd = {}, androidPhysicalClaimsBySerial = {}, allClaimed = false }) {
244
+ // Show every AVD on disk plus every physical device adb sees (parallel
245
+ // to the iOS picker), so the user can see what's claimed and optionally
246
+ // take it over. `candidates` is the unclaimed set passed in by
247
+ // selectAndroidDevice; devices outside it are claimed and require a
248
+ // confirm prompt on selection.
249
+ const all = enumerateAndroidCandidates();
250
+ const candidateKeys = new Set(candidates.map(c => candidateKey(c)));
251
+ const sorted = sortAndroidCandidates(all);
183
252
 
184
- const nameWidth = Math.max(...sorted.map(c => c.avdName.length), 18);
253
+ const nameWidth = Math.max(...sorted.map(c => candidateDisplayName(c).length), 18);
185
254
  const choices = sorted.map(c => {
186
- const claim = androidClaimsByAvd[c.avdName];
187
- const isCandidate = candidateAvds.has(c.avdName);
188
- const runTag = c.isRunning ? chalk.green(` [emulator-${c.consolePort}, running]`) : '';
255
+ const claim = c.kind === 'physical'
256
+ ? androidPhysicalClaimsBySerial[c.serial]
257
+ : androidClaimsByAvd[c.avdName];
258
+ const isCandidate = candidateKeys.has(candidateKey(c));
259
+ const runTag = c.kind === 'physical'
260
+ ? chalk.green(' [physical]')
261
+ : (c.isRunning ? chalk.green(` [emulator-${c.consolePort}, running]`) : '');
262
+ const name = candidateDisplayName(c);
189
263
  if (claim || !isCandidate) {
190
264
  const tag = claim ? chalk.yellow(` [claimed by ${claim.label}]`) : '';
191
265
  return {
192
- title: chalk.yellow(`${c.avdName.padEnd(nameWidth)}${tag}${runTag}`),
266
+ title: chalk.yellow(`${name.padEnd(nameWidth)}${tag}${runTag}`),
193
267
  value: { c, claim: claim || null },
194
268
  };
195
269
  }
196
270
  return {
197
- title: `${c.avdName.padEnd(nameWidth)}${runTag}`,
271
+ title: `${name.padEnd(nameWidth)}${runTag}`,
198
272
  value: { c, claim: null },
199
273
  };
200
274
  });
201
275
  const message = allClaimed
202
- ? 'All AVDs are claimed. Pick one to take over:'
203
- : 'Pick an AVD (claimed AVDs will prompt to confirm):';
276
+ ? 'All Android devices are claimed. Pick one to take over:'
277
+ : 'Pick an Android device (claimed devices will prompt to confirm):';
204
278
  const answer = await prompts({
205
279
  type: 'select',
206
280
  name: 'pick',
@@ -216,7 +290,7 @@ async function pickAvd({ candidates, androidClaimsByAvd = {}, allClaimed = false
216
290
  const ok = await prompts({
217
291
  type: 'confirm',
218
292
  name: 'ok',
219
- message: `${c.avdName} is currently held by project "${claim.label}". Take it over?`,
293
+ message: `${candidateDisplayName(c)} is currently held by project "${claim.label}". Take it over?`,
220
294
  initial: false,
221
295
  });
222
296
  if (!ok.ok) {
@@ -228,6 +302,14 @@ async function pickAvd({ candidates, androidClaimsByAvd = {}, allClaimed = false
228
302
  return { c, prevClaim: null };
229
303
  }
230
304
 
305
+ function candidateKey(c) {
306
+ return c.kind === 'physical' ? `p:${c.serial}` : `a:${c.avdName}`;
307
+ }
308
+
309
+ function candidateDisplayName(c) {
310
+ return c.kind === 'physical' ? c.serial : c.avdName;
311
+ }
312
+
231
313
  async function releasePriorClaim(prevClaim) {
232
314
  if (!prevClaim?.path) return;
233
315
  clearDevice(prevClaim.path, 'android');
@@ -27,15 +27,36 @@ export default function deviceCommand(program) {
27
27
  }
28
28
 
29
29
  if (opts.json) {
30
- const payload = opts.platform === 'ios'
31
- ? { platform: 'ios', udid: platformEntry.deviceUdid, metroPort: proj.metroPort }
32
- : { platform: 'android', serial: `emulator-${platformEntry.consolePort}`, avdName: platformEntry.avdName, consolePort: platformEntry.consolePort, metroPort: proj.metroPort };
30
+ let payload;
31
+ if (opts.platform === 'ios') {
32
+ payload = { platform: 'ios', udid: platformEntry.deviceUdid, metroPort: proj.metroPort };
33
+ } else if (platformEntry.serial && !platformEntry.avdName) {
34
+ payload = {
35
+ platform: 'android',
36
+ kind: 'physical',
37
+ serial: platformEntry.serial,
38
+ avdName: null,
39
+ consolePort: null,
40
+ metroPort: proj.metroPort,
41
+ };
42
+ } else {
43
+ payload = {
44
+ platform: 'android',
45
+ kind: 'emulator',
46
+ serial: `emulator-${platformEntry.consolePort}`,
47
+ avdName: platformEntry.avdName,
48
+ consolePort: platformEntry.consolePort,
49
+ metroPort: proj.metroPort,
50
+ };
51
+ }
33
52
  console.log(JSON.stringify(payload));
34
53
  return;
35
54
  }
36
55
 
37
56
  if (opts.platform === 'ios') {
38
57
  console.log(platformEntry.deviceUdid);
58
+ } else if (platformEntry.serial && !platformEntry.avdName) {
59
+ console.log(platformEntry.serial);
39
60
  } else {
40
61
  console.log(`emulator-${platformEntry.consolePort}`);
41
62
  }
@@ -46,9 +46,11 @@ export default function releaseCommand(program) {
46
46
  if (p === 'ios') {
47
47
  shutdownIosSim(entry.deviceUdid);
48
48
  console.log(chalk.green(`Shut down iOS sim ${formatIosLabel(entry.deviceUdid)}`));
49
- } else {
49
+ } else if (entry.avdName && typeof entry.consolePort === 'number') {
50
50
  shutdownAndroidEmulator(`emulator-${entry.consolePort}`);
51
51
  console.log(chalk.green(`Shut down ${entry.avdName} (emulator-${entry.consolePort})`));
52
+ } else if (entry.serial) {
53
+ console.log(chalk.dim(`Skipping shutdown for physical device ${entry.serial}`));
52
54
  }
53
55
  }
54
56
  clearDevice(found, p);
@@ -0,0 +1,120 @@
1
+ // src/commands/shutdown.js
2
+ import chalk from 'chalk';
3
+ import prompts from 'prompts';
4
+ import { resolveRegisteredProject } from '../project.js';
5
+ import { loadConfig, setMetro, clearDevice } from '../config.js';
6
+ import { killMetroByPid, findPidListeningOnPort } from '../metro.js';
7
+ import { shutdownIosSim, formatIosLabel } from '../sim/ios.js';
8
+ import { shutdownAndroidEmulator } from '../sim/android.js';
9
+
10
+ export default function shutdownCommand(program) {
11
+ program
12
+ .command('shutdown [target]')
13
+ .description('Stop Metro and shut down sims/emulators across rn-iso projects, then clear their device assignments. With no arg, targets every registered project; pass a project shortcut (label or unique basename) or absolute path to scope to one.')
14
+ .option('-y, --yes', 'Skip the confirmation prompt (also implied when stdin is not a TTY)')
15
+ .option('--keep-sims', "Don't shut down simulators/emulators; only kill Metro and clear assignments")
16
+ .action(async (target, opts) => {
17
+ const cfg = loadConfig();
18
+ let projects = cfg ? Object.entries(cfg.projects || {}) : [];
19
+ if (projects.length === 0) {
20
+ console.log(chalk.dim('No projects registered.'));
21
+ return;
22
+ }
23
+
24
+ // Optional [target] narrows the scope to a single project. We
25
+ // intentionally do NOT default to the current project when no arg is
26
+ // given — `shutdown` is the explicit "tear everything down" command,
27
+ // so omitting the target means "all projects".
28
+ if (target) {
29
+ const { found, error } = resolveRegisteredProject(target);
30
+ if (!found) {
31
+ console.error(chalk.red(error));
32
+ process.exit(1);
33
+ }
34
+ projects = projects.filter(([path]) => path === found);
35
+ }
36
+
37
+ // Build the work plan up front so the prompt can show counts and so we
38
+ // do all the I/O in clearly separated phases.
39
+ const metros = []; // { path, port, pid }
40
+ const iosSims = []; // { path, udid }
41
+ const androidEmus = []; // { path, avdName, consolePort }
42
+ for (const [path, proj] of projects) {
43
+ if (typeof proj.metroPort === 'number') {
44
+ metros.push({ path, port: proj.metroPort, pid: proj.metroPid });
45
+ }
46
+ const ios = proj.platforms?.ios;
47
+ if (ios?.deviceUdid) iosSims.push({ path, udid: ios.deviceUdid });
48
+ const android = proj.platforms?.android;
49
+ if (android?.avdName || typeof android?.consolePort === 'number') {
50
+ androidEmus.push({ path, avdName: android.avdName, consolePort: android.consolePort });
51
+ }
52
+ }
53
+
54
+ const hasDeviceAssignments = iosSims.length > 0 || androidEmus.length > 0;
55
+ if (metros.length === 0 && !hasDeviceAssignments) {
56
+ console.log(chalk.dim('Nothing to do (no Metro / device assignments tracked).'));
57
+ return;
58
+ }
59
+
60
+ const yes = opts.yes || !process.stdin.isTTY;
61
+ if (!yes) {
62
+ const summary = [];
63
+ if (metros.length) summary.push(`kill ${metros.length} Metro instance${metros.length === 1 ? '' : 's'}`);
64
+ if (!opts.keepSims) {
65
+ if (iosSims.length) summary.push(`shut down ${iosSims.length} iOS sim${iosSims.length === 1 ? '' : 's'}`);
66
+ if (androidEmus.length) summary.push(`shut down ${androidEmus.length} Android emulator${androidEmus.length === 1 ? '' : 's'}`);
67
+ }
68
+ if (hasDeviceAssignments) summary.push('clear device assignments');
69
+ const answer = await prompts({
70
+ type: 'confirm',
71
+ name: 'ok',
72
+ message: `About to ${summary.join(', ')} across ${projects.length} project${projects.length === 1 ? '' : 's'}. Proceed?`,
73
+ initial: false,
74
+ });
75
+ if (!answer.ok) {
76
+ console.error(chalk.red('Cancelled.'));
77
+ process.exit(1);
78
+ }
79
+ }
80
+
81
+ // Phase 1: kill Metro instances. Try the recorded pid first; if that
82
+ // misses, look up whoever's listening on the port. Always clear the
83
+ // recorded metroPid so `status` reflects reality afterward.
84
+ for (const m of metros) {
85
+ let pid = m.pid;
86
+ if (!pid || !killMetroByPid(pid)) {
87
+ pid = findPidListeningOnPort(m.port);
88
+ if (pid) killMetroByPid(pid);
89
+ }
90
+ setMetro(m.path, m.port, null);
91
+ if (pid) {
92
+ console.log(chalk.green(`Killed Metro pid ${pid} on port ${m.port} ${chalk.dim(`(${m.path})`)}`));
93
+ } else {
94
+ console.log(chalk.dim(`No Metro running on port ${m.port} (${m.path})`));
95
+ }
96
+ }
97
+
98
+ // Phase 2: shut down sims / emulators. shutdownIosSim and
99
+ // shutdownAndroidEmulator both go through runQuiet so failures (e.g.
100
+ // sim already shut down, adb missing) don't throw.
101
+ if (!opts.keepSims) {
102
+ for (const s of iosSims) {
103
+ shutdownIosSim(s.udid);
104
+ console.log(chalk.green(`Shut down iOS sim ${formatIosLabel(s.udid)} ${chalk.dim(`(${s.path})`)}`));
105
+ }
106
+ for (const a of androidEmus) {
107
+ const serial = `emulator-${a.consolePort}`;
108
+ shutdownAndroidEmulator(serial);
109
+ console.log(chalk.green(`Shut down ${a.avdName ?? serial} (${serial}) ${chalk.dim(`(${a.path})`)}`));
110
+ }
111
+ }
112
+
113
+ // Phase 3: clear device assignments so subsequent `rn-iso ios/android`
114
+ // calls re-pick instead of trying to reuse a now-shutdown device.
115
+ for (const [path, proj] of projects) {
116
+ if (proj.platforms?.ios) clearDevice(path, 'ios');
117
+ if (proj.platforms?.android) clearDevice(path, 'android');
118
+ }
119
+ });
120
+ }
@@ -8,8 +8,9 @@ import { ensureMetro } from '../metro.js';
8
8
  export default function startCommand(program) {
9
9
  program
10
10
  .command('start')
11
- .description('Ensure Metro is running for the current project (no platform action)')
12
- .action(async () => {
11
+ .description('Ensure Metro is running for the current project (no platform action). Pass extra flags to `expo start` / `react-native start` after `--`, e.g. `rn-iso start -- --reset-cache`.')
12
+ .argument('[extras...]', 'Flags forwarded as-is to expo/react-native start (after `--`)')
13
+ .action(async (extras) => {
13
14
  const root = findProjectRoot(process.cwd());
14
15
  if (!root) {
15
16
  console.error(chalk.red('Not in a React Native project (no package.json found).'));
@@ -32,9 +33,16 @@ export default function startCommand(program) {
32
33
  proj = getProject(root);
33
34
  }
34
35
 
35
- const metro = await ensureMetro({ projectPath: root, isExpo, port: proj.metroPort });
36
+ const metro = await ensureMetro({ projectPath: root, isExpo, port: proj.metroPort, extras });
36
37
  if (metro.alreadyRunning) {
37
- console.log(chalk.dim(`Metro already running on port ${proj.metroPort}`));
38
+ if (extras?.length) {
39
+ console.log(chalk.yellow(
40
+ `Metro already running on port ${proj.metroPort}; extras (${extras.join(' ')}) were not applied. ` +
41
+ `Run \`rn-iso stop\` first and re-run to apply them.`
42
+ ));
43
+ } else {
44
+ console.log(chalk.dim(`Metro already running on port ${proj.metroPort}`));
45
+ }
38
46
  } else {
39
47
  setMetro(root, proj.metroPort, metro.pid);
40
48
  console.log(chalk.green(`Metro started (pid ${metro.pid}, port ${proj.metroPort})`));
@@ -53,7 +53,11 @@ export default function statusCommand(program) {
53
53
  }
54
54
  const android = proj.platforms?.android;
55
55
  if (android) {
56
- console.log(` android: ${chalk.cyan(android.avdName)} ${chalk.dim(`(emulator-${android.consolePort})`)}`);
56
+ if (android.serial && !android.avdName) {
57
+ console.log(` android: ${chalk.cyan(android.serial)} ${chalk.dim('(physical)')}`);
58
+ } else {
59
+ console.log(` android: ${chalk.cyan(android.avdName)} ${chalk.dim(`(emulator-${android.consolePort})`)}`);
60
+ }
57
61
  }
58
62
  }
59
63
  console.log('');
package/src/config.js CHANGED
@@ -174,13 +174,16 @@ export function allClaimedDevices() {
174
174
  iosUdids: [],
175
175
  androidAvds: [],
176
176
  androidConsolePorts: [],
177
+ androidPhysicalSerials: [],
177
178
  // iosClaims: udid -> { label, path }. androidClaims: consolePort ->
178
179
  // { label, path, avdName }. androidClaimsByAvd: avdName -> { label,
179
- // path, consolePort }. `path` is the absolute project path so take-
180
- // over flows can call clearDevice on the owning project.
180
+ // path, consolePort }. androidPhysicalClaimsBySerial: serial ->
181
+ // { label, path }. `path` is the absolute project path so take-over
182
+ // flows can call clearDevice on the owning project.
181
183
  iosClaims: {},
182
184
  androidClaims: {},
183
185
  androidClaimsByAvd: {},
186
+ androidPhysicalClaimsBySerial: {},
184
187
  };
185
188
  if (!cfg) return result;
186
189
  for (const [path, proj] of Object.entries(cfg.projects || {})) {
@@ -207,6 +210,10 @@ export function allClaimedDevices() {
207
210
  avdName: android.avdName,
208
211
  };
209
212
  }
213
+ if (android?.serial && !android.avdName) {
214
+ result.androidPhysicalSerials.push(android.serial);
215
+ result.androidPhysicalClaimsBySerial[android.serial] = { label, path };
216
+ }
210
217
  }
211
218
  return result;
212
219
  }
package/src/metro.js CHANGED
@@ -15,22 +15,23 @@ export function logFileFor(projectPath) {
15
15
  return join(dir, `${projectHash(projectPath)}.log`);
16
16
  }
17
17
 
18
- export function buildMetroSpawnArgs({ isExpo, port }) {
18
+ export function buildMetroSpawnArgs({ isExpo, port, extras = [] }) {
19
+ const base = isExpo
20
+ ? ['expo', 'start', '--port', String(port)]
21
+ : ['react-native', 'start', '--port', String(port)];
19
22
  return {
20
23
  cmd: 'npx',
21
- args: isExpo
22
- ? ['expo', 'start', '--port', String(port)]
23
- : ['react-native', 'start', '--port', String(port)],
24
+ args: [...base, ...extras],
24
25
  };
25
26
  }
26
27
 
27
- export async function ensureMetro({ projectPath, isExpo, port, detach = true }) {
28
+ export async function ensureMetro({ projectPath, isExpo, port, extras = [], detach = true }) {
28
29
  if (await isMetroRunning(port)) return { alreadyRunning: true, pid: null };
29
30
 
30
31
  const log = logFileFor(projectPath);
31
32
  const fd = openSync(log, 'a');
32
33
 
33
- const { cmd, args } = buildMetroSpawnArgs({ isExpo, port });
34
+ const { cmd, args } = buildMetroSpawnArgs({ isExpo, port, extras });
34
35
  const exec = getExecutor();
35
36
  const child = exec.spawn(cmd, args, {
36
37
  cwd: projectPath,
package/src/runner.js CHANGED
@@ -105,12 +105,16 @@ export function buildIosCommand({ projectRoot, packageManager, scriptName, isExp
105
105
 
106
106
  export function buildAndroidCommand({ projectRoot, packageManager, scriptName, isExpo, avdName, serial, port, useScript = true, extras = [] }) {
107
107
  const tail = (extras || []).map(shQuote);
108
+ // Expo `--device <id>` accepts either an AVD name (emulators) or a
109
+ // hardware serial (physical devices). When no AVD name is available
110
+ // (physical), fall back to the serial.
111
+ const expoDeviceArg = avdName ?? serial;
108
112
  if (useScript && scriptName) {
109
113
  const script = getProjectScript(projectRoot, scriptName);
110
114
  if (script) {
111
115
  const cli = detectScriptCli(script);
112
- // Expo: --device <AVD name>; RN: --deviceId <serial>.
113
- const deviceFlag = cli === 'expo' ? `--device "${avdName}"` : `--deviceId ${serial}`;
116
+ // Expo: --device <AVD name | serial>; RN: --deviceId <serial>.
117
+ const deviceFlag = cli === 'expo' ? `--device "${expoDeviceArg}"` : `--deviceId ${serial}`;
114
118
  return buildScriptCommand(packageManager, scriptName, [
115
119
  deviceFlag,
116
120
  `--port ${port}`,
@@ -120,7 +124,7 @@ export function buildAndroidCommand({ projectRoot, packageManager, scriptName, i
120
124
  }
121
125
  const tailStr = tail.length ? ' ' + tail.join(' ') : '';
122
126
  if (isExpo) {
123
- return `npx expo run:android --device "${avdName}" --port ${port}${tailStr}`;
127
+ return `npx expo run:android --device "${expoDeviceArg}" --port ${port}${tailStr}`;
124
128
  }
125
129
  return `RCT_METRO_PORT=${port} npx react-native run-android --deviceId ${serial}${tailStr}`;
126
130
  }
@@ -10,15 +10,28 @@ export function parseAvdList(text) {
10
10
  export function parseAdbDevices(text) {
11
11
  const lines = text.split('\n').slice(1); // skip "List of devices attached"
12
12
  const emulators = [];
13
+ const physical = []; // USB or adb-over-TCP serials (e.g. R5CR70XXX, 192.168.1.5:5555)
14
+ const unhealthy = []; // serials adb sees but can't talk to (unauthorized/offline)
13
15
  for (const line of lines) {
14
16
  const trimmed = line.trim();
15
17
  if (!trimmed) continue;
16
18
  const [serial, status] = trimmed.split(/\s+/);
17
- if (status !== 'device') continue;
18
19
  const m = serial.match(/^emulator-(\d+)$/);
19
- if (m) emulators.push({ serial, consolePort: parseInt(m[1], 10) });
20
+ if (m) {
21
+ if (status === 'device') {
22
+ emulators.push({ serial, consolePort: parseInt(m[1], 10) });
23
+ } else {
24
+ unhealthy.push({ serial, kind: 'emulator', consolePort: parseInt(m[1], 10), status });
25
+ }
26
+ } else {
27
+ if (status === 'device') {
28
+ physical.push({ serial });
29
+ } else {
30
+ unhealthy.push({ serial, kind: 'physical', status });
31
+ }
32
+ }
20
33
  }
21
- return { emulators };
34
+ return { emulators, physical, unhealthy };
22
35
  }
23
36
 
24
37
  export function listAvds() {
@@ -35,37 +48,66 @@ export function nextConsolePort(claimedPorts) {
35
48
  return max + 2; // emulator console ports are even
36
49
  }
37
50
 
38
- // Build the full candidate list: every AVD on disk, paired with whether it
39
- // currently has a running emulator and (if so) on which console port.
40
- // Emulators that fail to respond to `adb emu avd name` are dropped from
41
- // the running map. Returns [] when no AVDs are installed.
51
+ // Build the full candidate list: every AVD on disk plus every physical
52
+ // device adb currently sees. AVDs are paired with whether they have a
53
+ // running emulator (and on which console port); physical devices are
54
+ // always treated as running. Returns [] when neither exist.
42
55
  export function enumerateAndroidCandidates() {
43
56
  const avds = listAvds();
44
- if (avds.length === 0) return [];
45
-
46
57
  const adbDevices = listAdbDevices();
58
+
47
59
  const runningByAvd = {};
48
60
  for (const e of adbDevices.emulators) {
49
61
  const avdName = getAvdNameForSerial(e.serial);
50
62
  if (avdName) runningByAvd[avdName] = e.consolePort;
51
63
  }
52
64
 
53
- return avds.map(avdName => ({
65
+ const avdCandidates = avds.map(avdName => ({
66
+ kind: 'avd',
54
67
  avdName,
55
68
  isRunning: avdName in runningByAvd,
56
69
  consolePort: runningByAvd[avdName] ?? null,
57
70
  }));
71
+
72
+ const physicalCandidates = adbDevices.physical.map(p => ({
73
+ kind: 'physical',
74
+ serial: p.serial,
75
+ isRunning: true,
76
+ consolePort: null,
77
+ }));
78
+
79
+ return [...avdCandidates, ...physicalCandidates];
58
80
  }
59
81
 
60
- export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedAvds, claimedConsolePorts }) {
82
+ export function selectAndroidDevice({
83
+ existingAvd,
84
+ existingSerial,
85
+ existingConsolePort,
86
+ claimedAvds,
87
+ claimedSerials,
88
+ claimedConsolePorts,
89
+ }) {
61
90
  const all = enumerateAndroidCandidates();
62
91
  if (all.length === 0) return { kind: 'noAvd' };
63
92
 
93
+ if (existingSerial) {
94
+ const found = all.find(c => c.kind === 'physical' && c.serial === existingSerial);
95
+ if (found) {
96
+ return {
97
+ kind: 'reuse',
98
+ deviceKind: 'physical',
99
+ serial: existingSerial,
100
+ isRunning: true,
101
+ };
102
+ }
103
+ }
104
+
64
105
  if (existingAvd) {
65
- const found = all.find(c => c.avdName === existingAvd);
106
+ const found = all.find(c => c.kind === 'avd' && c.avdName === existingAvd);
66
107
  if (found) {
67
108
  return {
68
109
  kind: 'reuse',
110
+ deviceKind: 'avd',
69
111
  avdName: existingAvd,
70
112
  consolePort: found.consolePort ?? existingConsolePort ?? nextConsolePort(claimedConsolePorts),
71
113
  isRunning: found.isRunning,
@@ -74,7 +116,10 @@ export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedA
74
116
  }
75
117
 
76
118
  const claimedAvdSet = new Set(claimedAvds);
77
- const unclaimed = all.filter(c => !claimedAvdSet.has(c.avdName));
119
+ const claimedSerialSet = new Set(claimedSerials || []);
120
+ const unclaimed = all.filter(c => c.kind === 'avd'
121
+ ? !claimedAvdSet.has(c.avdName)
122
+ : !claimedSerialSet.has(c.serial));
78
123
 
79
124
  if (unclaimed.length === 0) {
80
125
  return { kind: 'allClaimed', candidates: sortAndroidCandidates(all) };
@@ -85,7 +130,11 @@ export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedA
85
130
  export function sortAndroidCandidates(list) {
86
131
  return [...list].sort((a, b) => {
87
132
  if (a.isRunning !== b.isRunning) return a.isRunning ? -1 : 1;
88
- return a.avdName.localeCompare(b.avdName);
133
+ // Mixed lists: physical devices float above AVDs within the same running state.
134
+ if (a.kind !== b.kind) return a.kind === 'physical' ? -1 : 1;
135
+ const an = a.kind === 'physical' ? a.serial : a.avdName;
136
+ const bn = b.kind === 'physical' ? b.serial : b.avdName;
137
+ return an.localeCompare(bn);
89
138
  });
90
139
  }
91
140
 
@@ -101,11 +150,26 @@ export async function waitForBoot(serial, timeoutMs = 60000) {
101
150
  const exec = getExecutor();
102
151
  const start = Date.now();
103
152
  while (Date.now() - start < timeoutMs) {
104
- const out = exec.runQuiet(`adb -s ${serial} shell getprop sys.boot_completed`);
105
- if (out && out.trim() === '1') return true;
153
+ // sys.boot_completed is the canonical "system fully up" signal; some
154
+ // older AVD images set dev.bootcomplete sooner. Either is fine.
155
+ const sysBoot = exec.runQuiet(`adb -s ${serial} shell getprop sys.boot_completed`).trim();
156
+ if (sysBoot === '1') return { ok: true };
157
+ const devBoot = exec.runQuiet(`adb -s ${serial} shell getprop dev.bootcomplete`).trim();
158
+ if (devBoot === '1') return { ok: true };
106
159
  await new Promise(r => setTimeout(r, 1000));
107
160
  }
108
- return false;
161
+ // Diagnostic snapshot for the timeout error: shows the user exactly
162
+ // what adb sees and why the polling never resolved.
163
+ const devices = exec.runQuiet('adb devices').trim();
164
+ return {
165
+ ok: false,
166
+ diagnostic: {
167
+ devices,
168
+ sysBoot: exec.runQuiet(`adb -s ${serial} shell getprop sys.boot_completed`).trim(),
169
+ devBoot: exec.runQuiet(`adb -s ${serial} shell getprop dev.bootcomplete`).trim(),
170
+ bootAnim: exec.runQuiet(`adb -s ${serial} shell getprop init.svc.bootanim`).trim(),
171
+ },
172
+ };
109
173
  }
110
174
 
111
175
  export function shutdownAndroidEmulator(serial) {