rn-iso 0.2.1 → 0.3.2

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
@@ -47,7 +47,7 @@ All commands below take the same `npx rn-iso` prefix.
47
47
  | `status` | Show all projects' state |
48
48
  | `reserve [ios\|android]` | Lock a manually-started sim/emulator to the current project (no build) |
49
49
  | `unreserve [ios\|android]` | Drop the current project's lock without shutting the sim down |
50
- | `release [<shortcut>\|<path>] [--platform <p>] [--shutdown]` | Free a project's assignment; `--shutdown` also stops the sim |
50
+ | `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. |
51
51
 
52
52
  ## How it works
53
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rn-iso",
3
- "version": "0.2.1",
3
+ "version": "0.3.2",
4
4
  "description": "Isolated React Native dev environments per project/worktree",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -82,7 +82,7 @@ Reserve binds the sim to the current project the same way `ios` does, but skips
82
82
  - `npx rn-iso status` — show all projects, their assignments, and Metro state.
83
83
  - `npx rn-iso start` — start Metro detached on the project's assigned port WITHOUT building/installing. Useful to keep Metro alive across builds.
84
84
  - `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.
85
- - `npx rn-iso release [<shortcut>|<path>] [--platform <p>] [--shutdown]` — free a project's sim assignment. Defaults to the current project. `--shutdown` also stops the sim/emulator.
85
+ - `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.
86
86
 
87
87
  ### Project shortcuts (--label)
88
88
 
@@ -7,6 +7,7 @@ import { allocatePort, isMetroRunning } from '../ports.js';
7
7
  import {
8
8
  selectAndroidDevice,
9
9
  sortAndroidCandidates,
10
+ enumerateAndroidCandidates,
10
11
  bootAndroidEmulator,
11
12
  waitForBoot,
12
13
  adbReverse,
@@ -77,16 +78,16 @@ export default function androidCommand(program) {
77
78
  if (selection.kind === 'reuse') {
78
79
  ({ avdName, consolePort, isRunning } = selection);
79
80
  if (isRunning) {
80
- console.log(chalk.dim(`Reusing running emulator emulator-${consolePort} (${avdName})`));
81
+ console.log(chalk.dim(`Reusing running ${avdName} (emulator-${consolePort})`));
81
82
  } else {
82
- console.log(chalk.dim(`Booting assigned AVD ${avdName} on port ${consolePort}...`));
83
+ console.log(chalk.dim(`Booting assigned ${avdName} (emulator-${consolePort})...`));
83
84
  }
84
85
  } else if (selection.kind === 'allocate') {
85
86
  const picked = (selection.candidates.length === 1 || auto)
86
87
  ? { c: selection.candidates[0], prevClaim: null }
87
88
  : await pickAvd({
88
89
  candidates: selection.candidates,
89
- androidClaims: claimed.androidClaims,
90
+ androidClaimsByAvd: claimed.androidClaimsByAvd,
90
91
  });
91
92
  await releasePriorClaim(picked.prevClaim);
92
93
  ({ avdName, isRunning, consolePort } = picked.c);
@@ -94,8 +95,8 @@ export default function androidCommand(program) {
94
95
  consolePort = nextConsolePort(claimedPorts);
95
96
  }
96
97
  console.log(isRunning
97
- ? chalk.green(`Picked ${avdName} (running on emulator-${consolePort})`)
98
- : chalk.dim(`Booting ${avdName} on port ${consolePort}...`));
98
+ ? chalk.green(`Picked ${avdName} (emulator-${consolePort}, running)`)
99
+ : chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
99
100
  } else if (selection.kind === 'allClaimed') {
100
101
  if (auto) {
101
102
  console.error(chalk.red('All Android AVDs are claimed by other rn-iso projects.'));
@@ -104,7 +105,7 @@ export default function androidCommand(program) {
104
105
  }
105
106
  const picked = await pickAvd({
106
107
  candidates: selection.candidates,
107
- androidClaims: claimed.androidClaims,
108
+ androidClaimsByAvd: claimed.androidClaimsByAvd,
108
109
  allClaimed: true,
109
110
  });
110
111
  await releasePriorClaim(picked.prevClaim);
@@ -115,8 +116,8 @@ export default function androidCommand(program) {
115
116
  consolePort = nextConsolePort(fresh);
116
117
  }
117
118
  console.log(isRunning
118
- ? chalk.green(`Took over ${avdName} (running on emulator-${consolePort})`)
119
- : chalk.dim(`Booting ${avdName} on port ${consolePort}...`));
119
+ ? chalk.green(`Took over ${avdName} (emulator-${consolePort}, running)`)
120
+ : chalk.dim(`Booting ${avdName} (emulator-${consolePort})...`));
120
121
  } else {
121
122
  console.error(chalk.red(
122
123
  'No AVDs available. Create one via Android Studio (Tools -> Device Manager).'
@@ -161,20 +162,34 @@ export default function androidCommand(program) {
161
162
  });
162
163
  }
163
164
 
164
- console.log(chalk.green(`\nAndroid ready on ${serial}, Metro port ${proj.metroPort}`));
165
+ console.log(chalk.green(`\nAndroid ready on ${avdName} (${serial}), Metro port ${proj.metroPort}`));
165
166
  });
166
167
  }
167
168
 
168
- async function pickAvd({ candidates, androidClaims = {}, allClaimed = false }) {
169
- const sorted = sortAndroidCandidates(candidates);
169
+ async function pickAvd({ candidates, androidClaimsByAvd = {}, allClaimed = false }) {
170
+ // Show every AVD on disk (parallel to the iOS picker), so the user can
171
+ // see what's claimed and optionally take it over. `candidates` is the
172
+ // unclaimed set passed in by selectAndroidDevice; AVDs outside it are
173
+ // claimed and will require a confirm prompt on selection.
174
+ const allAvds = enumerateAndroidCandidates();
175
+ const candidateAvds = new Set(candidates.map(c => c.avdName));
176
+ const sorted = sortAndroidCandidates(allAvds);
177
+
170
178
  const nameWidth = Math.max(...sorted.map(c => c.avdName.length), 18);
171
179
  const choices = sorted.map(c => {
172
- const claim = androidClaims[c.consolePort];
173
- const claimTag = claim ? chalk.yellow(` [claimed by ${claim.label}]`) : '';
174
- const runTag = c.isRunning ? chalk.green(` [running on emulator-${c.consolePort}]`) : '';
180
+ const claim = androidClaimsByAvd[c.avdName];
181
+ const isCandidate = candidateAvds.has(c.avdName);
182
+ const runTag = c.isRunning ? chalk.green(` [emulator-${c.consolePort}, running]`) : '';
183
+ if (claim || !isCandidate) {
184
+ const tag = claim ? chalk.yellow(` [claimed by ${claim.label}]`) : '';
185
+ return {
186
+ title: chalk.yellow(`${c.avdName.padEnd(nameWidth)}${tag}${runTag}`),
187
+ value: { c, claim: claim || null },
188
+ };
189
+ }
175
190
  return {
176
- title: `${c.avdName.padEnd(nameWidth)}${runTag}${claimTag}`,
177
- value: { c, claim: claim || null },
191
+ title: `${c.avdName.padEnd(nameWidth)}${runTag}`,
192
+ value: { c, claim: null },
178
193
  };
179
194
  });
180
195
  const message = allClaimed
@@ -4,7 +4,7 @@ import prompts from 'prompts';
4
4
  import { findProjectRoot, detectIsExpo, detectBundleId, detectAndroidPackage } from '../project.js';
5
5
  import { getProject, upsertProject, setMetro, setDevice, clearDevice, allClaimedDevices, recordSimUsage, getSimUsage } from '../config.js';
6
6
  import { allocatePort, isMetroRunning } from '../ports.js';
7
- import { selectIosDevice, bootIosSim, listIosRuntimes, createIosSim, parseRuntimeVersion, listAllIosSims, sortSims } from '../sim/ios.js';
7
+ import { selectIosDevice, bootIosSim, listIosRuntimes, createIosSim, parseRuntimeVersion, listAllIosSims, sortSims, formatIosLabel } from '../sim/ios.js';
8
8
  import { buildIosCommand, detectPackageManager } from '../runner.js';
9
9
  import { getExecutor } from '../exec.js';
10
10
  import { resolveLabel } from '../labels.js';
@@ -70,11 +70,12 @@ export default function iosCommand(program) {
70
70
  let udid;
71
71
  if (selection.kind === 'reuse') {
72
72
  udid = selection.udid;
73
+ const label = `${selection.name} (${udid})`;
73
74
  if (selection.state !== 'Booted') {
74
- console.log(chalk.dim(`Booting assigned sim ${udid}...`));
75
+ console.log(chalk.dim(`Booting assigned sim ${label}...`));
75
76
  bootIosSim(udid);
76
77
  } else {
77
- console.log(chalk.dim(`Reusing assigned sim ${udid} (already booted)`));
78
+ console.log(chalk.dim(`Reusing assigned sim ${label} (already booted)`));
78
79
  }
79
80
  } else if (selection.kind === 'allocate') {
80
81
  const picked = (selection.candidates.length === 1 || auto)
@@ -99,7 +100,7 @@ export default function iosCommand(program) {
99
100
  if (auto) {
100
101
  if (opts.deviceType) {
101
102
  udid = createNewSim({ deviceType: opts.deviceType, runtimeVersion: opts.runtime });
102
- console.log(chalk.green(`Created and booted new sim ${udid}`));
103
+ console.log(chalk.green(`Created and booted new sim ${formatIosLabel(udid)}`));
103
104
  } else {
104
105
  console.error(chalk.red('All iOS simulators are claimed by other rn-iso projects.'));
105
106
  console.error(chalk.dim('Re-run without --auto to confirm taking one over, or pass --device-type to create a new sim.'));
@@ -164,7 +165,7 @@ export default function iosCommand(program) {
164
165
 
165
166
  }
166
167
 
167
- console.log(chalk.green(`\nOK: iOS ready on sim ${udid}, Metro port ${proj.metroPort}`));
168
+ console.log(chalk.green(`\nOK: iOS ready on sim ${formatIosLabel(udid)}, Metro port ${proj.metroPort}`));
168
169
  });
169
170
  }
170
171
 
@@ -1,21 +1,34 @@
1
1
  // src/commands/release.js
2
2
  import chalk from 'chalk';
3
+ import prompts from 'prompts';
3
4
  import { resolveRegisteredProject } from '../project.js';
4
- import { getProject, clearDevice } from '../config.js';
5
- import { shutdownIosSim } from '../sim/ios.js';
5
+ import { getProject, clearDevice, findProjectByMetroPort } from '../config.js';
6
+ import { findPidListeningOnPort } from '../metro.js';
7
+ import { shutdownIosSim, formatIosLabel } from '../sim/ios.js';
6
8
  import { shutdownAndroidEmulator } from '../sim/android.js';
7
9
 
8
10
  export default function releaseCommand(program) {
9
11
  program
10
12
  .command('release [target]')
11
- .description('Free a project assignment. [target] is an absolute project path; defaults to the current project.')
13
+ .description('Free a project assignment. [target] is a Metro port (e.g. 8083), a project shortcut (label or unique basename), or an absolute path. Defaults to the current project.')
12
14
  .option('--platform <platform>', 'ios or android (default: both)')
13
15
  .option('--shutdown', 'Also shut down the simulator/emulator after releasing')
14
- .action((target, opts) => {
15
- const { found, error } = resolveRegisteredProject(target);
16
- if (!found) {
17
- console.error(chalk.red(error));
18
- process.exit(1);
16
+ .action(async (target, opts) => {
17
+ let found;
18
+ if (target && /^\d+$/.test(target)) {
19
+ const port = parseInt(target, 10);
20
+ found = findProjectByMetroPort(port);
21
+ if (!found) {
22
+ await handleUnmatchedPort(port);
23
+ return;
24
+ }
25
+ } else {
26
+ const result = resolveRegisteredProject(target);
27
+ if (!result.found) {
28
+ console.error(chalk.red(result.error));
29
+ process.exit(1);
30
+ }
31
+ found = result.found;
19
32
  }
20
33
  const proj = getProject(found);
21
34
  if (!proj) {
@@ -32,10 +45,10 @@ export default function releaseCommand(program) {
32
45
  if (opts.shutdown) {
33
46
  if (p === 'ios') {
34
47
  shutdownIosSim(entry.deviceUdid);
35
- console.log(chalk.green(`Shut down iOS sim ${entry.deviceUdid}`));
48
+ console.log(chalk.green(`Shut down iOS sim ${formatIosLabel(entry.deviceUdid)}`));
36
49
  } else {
37
50
  shutdownAndroidEmulator(`emulator-${entry.consolePort}`);
38
- console.log(chalk.green(`Shut down emulator-${entry.consolePort}`));
51
+ console.log(chalk.green(`Shut down ${entry.avdName} (emulator-${entry.consolePort})`));
39
52
  }
40
53
  }
41
54
  clearDevice(found, p);
@@ -43,3 +56,33 @@ export default function releaseCommand(program) {
43
56
  }
44
57
  });
45
58
  }
59
+
60
+ async function handleUnmatchedPort(port) {
61
+ const pid = findPidListeningOnPort(port);
62
+ if (!pid) {
63
+ console.error(chalk.red(`No registered project has Metro port ${port}, and nothing is listening on that port.`));
64
+ process.exit(1);
65
+ }
66
+ console.log(chalk.dim(`No registered project has Metro port ${port}, but pid ${pid} is listening.`));
67
+ if (!process.stdin.isTTY) {
68
+ console.error(chalk.red('Refusing to kill an unrecognized process under non-TTY (no way to confirm).'));
69
+ process.exit(1);
70
+ }
71
+ const ok = await prompts({
72
+ type: 'confirm',
73
+ name: 'ok',
74
+ message: `Kill pid ${pid} on port ${port}?`,
75
+ initial: false,
76
+ });
77
+ if (!ok.ok) {
78
+ console.error(chalk.red('Cancelled.'));
79
+ process.exit(1);
80
+ }
81
+ try {
82
+ process.kill(pid, 'SIGTERM');
83
+ console.log(chalk.green(`Killed pid ${pid} on port ${port}.`));
84
+ } catch (e) {
85
+ console.error(chalk.red(`Could not kill pid ${pid}: ${e.message}`));
86
+ process.exit(1);
87
+ }
88
+ }
@@ -3,7 +3,8 @@ import chalk from 'chalk';
3
3
  import { loadConfig } from '../config.js';
4
4
  import { isMetroRunning } from '../ports.js';
5
5
  import { isPidAlive } from '../metro.js';
6
- import { findProjectRoot } from '../project.js';
6
+ import { findProjectRoot, projectShortcut } from '../project.js';
7
+ import { listAllIosSims } from '../sim/ios.js';
7
8
 
8
9
  export default function statusCommand(program) {
9
10
  program
@@ -19,11 +20,20 @@ export default function statusCommand(program) {
19
20
 
20
21
  const cwdRoot = findProjectRoot(process.cwd());
21
22
 
23
+ // Build a UDID -> sim name map so we can show "iPhone 16 Pro (UDID)"
24
+ // rather than a raw UDID. Tolerate missing simctl (Linux dev box, etc.).
25
+ let iosNameByUdid = {};
26
+ try {
27
+ for (const sim of listAllIosSims()) iosNameByUdid[sim.udid] = sim.name;
28
+ } catch { /* no simctl available; we'll just print the udid */ }
29
+
22
30
  for (const [path, proj] of Object.entries(cfg?.projects || {})) {
23
31
  const isCurrent = path === cwdRoot;
24
- const header = isCurrent ? chalk.bold.cyan(`* ${path}`) : path;
32
+ const shortcut = projectShortcut(path, proj);
33
+ const headerText = `${shortcut} ${chalk.dim(`(${path})`)}`;
34
+ const header = isCurrent ? chalk.bold.cyan(`* ${shortcut}`) + ' ' + chalk.dim(`(${path})`) : headerText;
25
35
  console.log('\n' + header);
26
- console.log(chalk.dim(` app: ${proj.bundleId} (${proj.isExpo ? 'expo' : 'bare'})`));
36
+ console.log(chalk.dim(` app: ${proj.bundleId ?? '?'} (${proj.isExpo ? 'expo' : 'bare'})`));
27
37
 
28
38
  if (proj.metroPort) {
29
39
  const running = await isMetroRunning(proj.metroPort);
@@ -37,9 +47,14 @@ export default function statusCommand(program) {
37
47
  }
38
48
 
39
49
  const ios = proj.platforms?.ios;
40
- if (ios) console.log(` ios: ${chalk.cyan(ios.deviceUdid)}`);
50
+ if (ios) {
51
+ const name = iosNameByUdid[ios.deviceUdid] || chalk.dim('(unknown sim)');
52
+ console.log(` ios: ${chalk.cyan(name)} ${chalk.dim(`(${ios.deviceUdid})`)}`);
53
+ }
41
54
  const android = proj.platforms?.android;
42
- if (android) console.log(` android: ${chalk.cyan(android.avdName)} on emulator-${android.consolePort}`);
55
+ if (android) {
56
+ console.log(` android: ${chalk.cyan(android.avdName)} ${chalk.dim(`(emulator-${android.consolePort})`)}`);
57
+ }
43
58
  }
44
59
  console.log('');
45
60
  });
@@ -1,9 +1,8 @@
1
1
  // src/commands/stop.js
2
2
  import chalk from 'chalk';
3
3
  import { resolveRegisteredProject } from '../project.js';
4
- import { loadConfig, getProject, setMetro } from '../config.js';
5
- import { killMetroByPid } from '../metro.js';
6
- import { getExecutor } from '../exec.js';
4
+ import { getProject, setMetro, findProjectByMetroPort } from '../config.js';
5
+ import { killMetroByPid, findPidListeningOnPort } from '../metro.js';
7
6
 
8
7
  export default function stopCommand(program) {
9
8
  program
@@ -65,19 +64,10 @@ function killByPort(port) {
65
64
  console.log(chalk.green(`Killed pid ${pid} on port ${port}`));
66
65
 
67
66
  // 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
- }
67
+ const owner = findProjectByMetroPort(port);
68
+ if (owner) {
69
+ setMetro(owner, port, null);
70
+ console.log(chalk.dim(`Cleared metroPid for ${owner}`));
75
71
  }
76
72
  }
77
73
 
78
- function findPidListeningOnPort(port) {
79
- const out = getExecutor().runQuiet(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`);
80
- if (!out) return null;
81
- const pid = parseInt(out.split('\n')[0], 10);
82
- return Number.isFinite(pid) ? pid : null;
83
- }
package/src/config.js CHANGED
@@ -96,6 +96,14 @@ export function allMetroPorts() {
96
96
  .filter(p => typeof p === 'number');
97
97
  }
98
98
 
99
+ export function findProjectByMetroPort(port) {
100
+ const cfg = loadConfig();
101
+ for (const [path, proj] of Object.entries(cfg?.projects || {})) {
102
+ if (proj.metroPort === port) return path;
103
+ }
104
+ return null;
105
+ }
106
+
99
107
  export function allClaimedDevices() {
100
108
  const cfg = loadConfig();
101
109
  const result = {
@@ -103,10 +111,12 @@ export function allClaimedDevices() {
103
111
  androidAvds: [],
104
112
  androidConsolePorts: [],
105
113
  // 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.
114
+ // { label, path, avdName }. androidClaimsByAvd: avdName -> { label,
115
+ // path, consolePort }. `path` is the absolute project path so take-
116
+ // over flows can call clearDevice on the owning project.
108
117
  iosClaims: {},
109
118
  androidClaims: {},
119
+ androidClaimsByAvd: {},
110
120
  };
111
121
  if (!cfg) return result;
112
122
  for (const [path, proj] of Object.entries(cfg.projects || {})) {
@@ -117,7 +127,14 @@ export function allClaimedDevices() {
117
127
  result.iosClaims[ios.deviceUdid] = { label, path };
118
128
  }
119
129
  const android = proj.platforms?.android;
120
- if (android?.avdName) result.androidAvds.push(android.avdName);
130
+ if (android?.avdName) {
131
+ result.androidAvds.push(android.avdName);
132
+ result.androidClaimsByAvd[android.avdName] = {
133
+ label,
134
+ path,
135
+ consolePort: android.consolePort,
136
+ };
137
+ }
121
138
  if (typeof android?.consolePort === 'number') {
122
139
  result.androidConsolePorts.push(android.consolePort);
123
140
  result.androidClaims[android.consolePort] = {
package/src/metro.js CHANGED
@@ -52,6 +52,13 @@ export function killMetroByPid(pid) {
52
52
  }
53
53
  }
54
54
 
55
+ export function findPidListeningOnPort(port) {
56
+ const out = getExecutor().runQuiet(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`);
57
+ if (!out) return null;
58
+ const pid = parseInt(out.split('\n')[0], 10);
59
+ return Number.isFinite(pid) ? pid : null;
60
+ }
61
+
55
62
  export function isPidAlive(pid) {
56
63
  if (!pid) return false;
57
64
  try {
@@ -35,37 +35,45 @@ export function nextConsolePort(claimedPorts) {
35
35
  return max + 2; // emulator console ports are even
36
36
  }
37
37
 
38
- export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedAvds, claimedConsolePorts }) {
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.
42
+ export function enumerateAndroidCandidates() {
39
43
  const avds = listAvds();
40
- if (avds.length === 0) return { kind: 'noAvd' };
44
+ if (avds.length === 0) return [];
41
45
 
42
46
  const adbDevices = listAdbDevices();
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
47
  const runningByAvd = {};
46
48
  for (const e of adbDevices.emulators) {
47
49
  const avdName = getAvdNameForSerial(e.serial);
48
50
  if (avdName) runningByAvd[avdName] = e.consolePort;
49
51
  }
50
52
 
51
- const buildCandidate = (avdName) => ({
53
+ return avds.map(avdName => ({
52
54
  avdName,
53
55
  isRunning: avdName in runningByAvd,
54
56
  consolePort: runningByAvd[avdName] ?? null,
55
- });
57
+ }));
58
+ }
56
59
 
57
- if (existingAvd && avds.includes(existingAvd)) {
58
- const runningPort = runningByAvd[existingAvd];
59
- return {
60
- kind: 'reuse',
61
- avdName: existingAvd,
62
- consolePort: runningPort ?? existingConsolePort ?? nextConsolePort(claimedConsolePorts),
63
- isRunning: runningPort != null,
64
- };
60
+ export function selectAndroidDevice({ existingAvd, existingConsolePort, claimedAvds, claimedConsolePorts }) {
61
+ const all = enumerateAndroidCandidates();
62
+ if (all.length === 0) return { kind: 'noAvd' };
63
+
64
+ if (existingAvd) {
65
+ const found = all.find(c => c.avdName === existingAvd);
66
+ if (found) {
67
+ return {
68
+ kind: 'reuse',
69
+ avdName: existingAvd,
70
+ consolePort: found.consolePort ?? existingConsolePort ?? nextConsolePort(claimedConsolePorts),
71
+ isRunning: found.isRunning,
72
+ };
73
+ }
65
74
  }
66
75
 
67
76
  const claimedAvdSet = new Set(claimedAvds);
68
- const all = avds.map(buildCandidate);
69
77
  const unclaimed = all.filter(c => !claimedAvdSet.has(c.avdName));
70
78
 
71
79
  if (unclaimed.length === 0) {
package/src/sim/ios.js CHANGED
@@ -31,6 +31,16 @@ export function listBootedIosSims() {
31
31
  return listAllIosSims().filter(s => s.state === 'Booted');
32
32
  }
33
33
 
34
+ // "iPhone 16 Pro (ABC-123-...)" if simctl knows about the UDID; the bare
35
+ // UDID otherwise (deleted sim, or simctl unavailable).
36
+ export function formatIosLabel(udid) {
37
+ try {
38
+ const sim = listAllIosSims().find(s => s.udid === udid);
39
+ if (sim) return `${sim.name} (${udid})`;
40
+ } catch { /* simctl not available */ }
41
+ return udid;
42
+ }
43
+
34
44
  export function deviceFamilyRank(name) {
35
45
  if (/^iPhone/i.test(name)) return 0;
36
46
  if (/^iPad/i.test(name)) return 1;
@@ -62,7 +72,7 @@ export function selectIosDevice({ existingUdid, claimedUdids, usage = {} }) {
62
72
  if (existingUdid) {
63
73
  const found = sims.find(s => s.udid === existingUdid);
64
74
  if (found) {
65
- return { kind: 'reuse', udid: found.udid, state: found.state };
75
+ return { kind: 'reuse', udid: found.udid, name: found.name, state: found.state };
66
76
  }
67
77
  }
68
78